Merge main into the Web PR (#230)
* Update to RN 71.1.0 (#100) * Update to RN 71 * Adds missing lint plugin * Add missing native changes * Bump @atproto/api@0.0.7 (#112) * Image not loading on swipe (#114) * Adds prefetching to images * Adds image prefetch * bugfix for images not showing on swipe * Fixes prefetch bug * Update src/view/com/util/PostEmbeds.tsx --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com> * Fixes to session management (#117) * Update session-management to solve incorrectly dropped sessions * Reset the nav on account switch * Reset the feed on me.load() * Update tests to reflect new account-switching behavior * Increase max image resolutions and sizes (#118) * Slightly increase the hitslop for post controls * Fix character counter color in dark mode * Update login to use new session.create api, which enables email login (close #93) (#119) * Replaces the alert with dropdown for profile image and banner (#123) * replaces the alert with dropdown for profile image and banner * lint * Fix to ordering of images in the embed grid (#121) * Add explicit link-embed controls to the composer (#120) * Add explicit link-embed controls * Update the target rez/size of link embed thumbs * Remove the alert before publishing without a link card * [Draft] Fixes image failing on reupload issue (#128) * Fixes image failing on reupload issue * Use tmp folder instead of documents * lint * Image performance improvements (#126) * Switch out most images for FastImage * Add image loading placeholders * Fix tests * Collection of fixes to list rendering (#127) * Fix bug that caused endless spinners in profile feeds * Bundle fetches of suggested actors into one update * Fixes to suggested follow rendering * Fix missing replacement of flex:1 to height:100 * Fixes to navigation swipes (#129) * Nav swipe: increase the distance traveled in response to gesture movement. This causes swipes to feel faster and more responsive. * Fix: fully clamp the swipe against the edge * Improve the performance of swipes by skipping the interaction manager * Adds dark mode to the edit screen (#130) * Adds dark mode to edit screen * lint * lint * lint * Reduce render cost of post controls and improve perceived responsiveness (#132) * Move post control animations into conditional render and increase perceived responsiveness * Remove log * Adds dark mode to the dropdown (#131) * Adds dark mode to the bottom sheet * Make background button lighter (like before) * lint * Fix bug in lightbox rendering (#133) * Fix layout in onboarding to not overflow the footer * Configure feed FlatList (removeClippedSubviews=true) to improve scroll performance (#136) * Disable like/repost animations to see if theyre causing #135 (#137) * Composer: mention tagging now works in middle of text (close #105) (#139) * Implement account deletion (#141) * Fix photo & camera permission management (#140) * Check photo & camera perms and alert the user if not available (close #64) - Adds perms checks with a prompt to update settings if needed - Moves initial access of photos in the composer so that the initial prompt occurs at an intuitive time. * Add react-native-permissions test mock * Fix issue causing multiple access requests * Use longer var names * Update podfile.lock * Lint fix * Move photo perm request in composer to the gallery btn instead of when the carousel is opened * Adds more tracking all around the app (#142) * Adds more tracking all around the app * more events * lint * using better analytics naming * missed file * more fixes * Calculate image aspect ratio on load (#146) * Calculate image aspect ratio on load * Move aspect ratio bounds to constants * Adds detox testing and instructions (#147) * Adds detox testing and instructions * lint * lint * Error cleanup (close #79) (#148) * Avoid surfacing errors to the user when it's not critical * Remove now-unused GetAssertionsView * Apply cleanError() consistently * Give a better error message for Upstream Failures (http status 502) * Hide errors in notifications because they're not useful * More e2e tests (create account) (#150) * Adds respots under the 'post' tab under profile (#158) * Adds dark mode to delete account screen (#159) * 87 dark mode edit profile (#162) * Adds dark mode to delete account screen * Adds one more missed darkmode * more fixes * Remove fallback gradient on external links without thumbs (#164) * Remove fallback gradient on external links without thumbs * Remove fallback gradient on external links without thumbs in the composer preview * Fix refresh behavior around a series of models (repost, graph, vote) (#163) * Fix refresh behavior around a series of models (repost, graph, vote) * Fix cursor behavior in reposted-by view * Fixes issue where retrying on image upload fails (#166) * Fixes issue where retrying on image upload fails * Lint, longer test time * Longer waitfor time in tests * even longer timeout * longer timeout * missed file * Update src/view/com/composer/ComposePost.tsx Co-authored-by: Paul Frazee <pfrazee@gmail.com> * Update src/view/com/composer/ComposePost.tsx Co-authored-by: Paul Frazee <pfrazee@gmail.com> --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com> * 154 cached image profile (#167) * Fixes issue where retrying on image upload fails * Lint, longer test time * Longer waitfor time in tests * even longer timeout * longer timeout * missed file * Fixes image cache error on second try for profile screen * lint * lint * lint * Refactor session management to use a new "Agent" API (#165) * Add the atp-agent implementation (temporarily in this repo) * Rewrite all session & API management to use the new atp-agent * Update tests for the atp-agent refactor * Refactor management of session-related state. Includes: - More careful management of when state is cleared or fetched - Debug logging to help trace future issues - Clearer APIs overall * Bubble session-expiration events to the user and display a toast to explain * Switch to the new @atproto/api@0.1.0 * Minor aesthetic cleanup in SessionModel * Wire up ReportAccount and ReportPost (#168) * Fixes embeds for youtube channels (#169) * Bump app ios version to 1.1 (needed after app store submission) * Fix potential issues with promise guards when an error occurs (#170) * Refactor models to use bundleAsync and lock regions (#171) * Fix to an edge case with feed re-ordering for threads (#172) * 151 fix youtube channel embed (#173) * Fixes embeds for youtube channels * Tests for youtube extract meta * lint * Add 'doesnt use non-exempt encryption' to ios config * Rework the search UI and add (#174) * Add search tab and move icon to footer * Remove subtitles from view header * Remove unused code * Clean up UI of search screen * Search: give better user feedback to UI state and add a cancel button * Add WhoToFollow section to search * Add a temporary SuggestedPosts solution using the patented 'bsky team algo' * Trigger reload of suggested content in search on open * Wait five min between reloading discovery content * Reduce weight of solid search icon in footer * Fix lint * Fix tests * 151 feat youtube embed iframe (#176) * youtube embed iframe temp commit * Fixes styling and code cleanup * lint * Now clicking between the pause and settings button doesn't trigger the parent * use modest branding (less yt logos) * Stop playing the video once there's a navigation event * Make sure the iframe is unmounted on any navigation event * fixes tests * lint * Add scroll-to-top for all screens (#177) * Adds hardcoded suggested list (#178) * Adds hardcoded suggested list * Update suggested-actors-view to support page sizes smaller than the hardcoded list --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com> * more robust centering of the play button (#181) Co-authored-by: Aryan Goharzad <arrygoo@gmail.com> * Bundle of UI modifications (#175) * Adjust visual balance of SuggestedPosts and WhoToFollow * Fix bug in the discovery load trigger * Adjust search header aesthetic and have it scroll away * More visual balance tweaks on the search page * Even more visual balance tweaks on the search page * Hide the footer on scroll in search * Ditch the composer prompt buttons in the home feed * Center the view header title * Hide header on scroll on the home feed * Fix e2e tests * Fix home feed positioning (closes #189) (#195) * Fix home feed positioning for floating header * Fix positioning of errors in home feed * Fix lint * Don't show new-content notification for reposts (close #179) (#197) * Show the splash screen during session resumption (close #186) (#199) * Fix to suggested follows: chunk the hardcoded fetches to 25 at a time (close #196) (#198) * UI updates to the floating action button (#201) * Update FAB to use a plus icon and not drop shadow * Update FAB positioning to be more consistent in different shell modes * Animate the FAB's repositioning * Remove the 'loading' placeholder from images as it degraded feed perf (#202) * Remove the 'loading' placeholder from images as it degraded feed perf * Remove references * Fix RN bug that causes home feed not to load more; also fix home feed load view. (#208) RN has a bug where rendering a flatlist with an empty array appears to break its virtual list windowing behaviors. See https://stackoverflow.com/a/67873596 * Only give the loading spinner on the home feed during PTR (#207) (cherry picked from commit b7a5da12fdfacef74873b5cf6d75f20d259bde0e) * Implement our own lifecycle tracking to ensure it never fires while the app is backgrounded (close #193) (#211) * Push notification fixes (#210) * Fix to when screen analytics events are firing * Fix: dont trigger update state when backgrounded * Small fix to notifee API usage * Fix: properly load notification info for push card * Add feedback link to main menu (close #191) (#212) * Add "follows you" information and sync follow state between views (#215) * Bump @atproto/api@0.1.2 and update API usage * Add 'follows you' pill to profile header (close #110) * Add 'follows you' to followers and follows (close #103) * Update reposted-by and liked-by views to use the same components as followers and following * Create a local follows cache MyFollowsModel to keep views in sync (close #205) * Add incremental hydration to the MyFollows model * Fix tests * Update deps * Fix lint * Fix to paginated fetches * Fix reference * Fix potential state-desync issue * Fixes to notifications (#216) * Improve push-notification for follows * Refresh notifications on screen open (close #214) * Avoid showing loader more than needed in post threads * Refactor notification polling to handle view-state more effectively * Delete a bunch of tests taht werent adding value * Remove the accounts integration test; we'll use the e2e test instead * Load latest in notifications when the screen is open rather than full refresh * Randomize hard-coded suggested follows (#226) * Ensure follows are loaded before filtering hardcoded suggestions * Randomize hard-coded suggested profiles (close #219) * Sanitizes posts on publish and render (#217) * Sanatizes posts on publish and render * lint * lint and added sanitize to thread view as well * adjusts indices based on replaced text * Woops, fixes a bug * bugfix + cleanup * comment * lint * move sanitize text to later in the flow * undo changes to compose post * Add RichText library building upon the sanitizePost library method * Add lodash.clonedeep dep * Switch to RichText processing on record load & render * Fix lint --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com> * A group of notifications fixes (#227) * Fix: don't group together notifications that can't visually be grouped (close #221) * Mark all notifications read on PTR * Small optimization: useCallback and useMemo in posts feed * Add loading spinner to footer of notifications (close #222) * Fix to scrolling to posts within a thread (#228) * Fix: render the entire thread at start so that scrollToIndex works always (close #270) * Visual fixes to thread 'load more' * A few small perf improvements to thread rendering * Fix lint * 1.2 * Remove unused logger lib * Remove state-mock * Type fixes * Reorganize the folder structure for lib and switch to typescript path aliases * Move build-flags into lib * Move to the state path alias * Add view path alias * Fix lint * iOS build fixes * Wrap analytics in native/web splitter and re-enable in all view code * Add web version of react-native-webview * Add web split for version number * Fix BlurView import for web * Add web split for fastimage * Create web split for permissions lib * Fix for web high priority images --------- Co-authored-by: Aryan Goharzad <arrygoo@gmail.com>
This commit is contained in:
parent
7916b26aad
commit
f28334739b
242 changed files with 8400 additions and 7454 deletions
|
@ -5,9 +5,9 @@ import {
|
|||
StyleSheet,
|
||||
useWindowDimensions,
|
||||
} from 'react-native'
|
||||
import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
|
||||
interface AutocompleteItem {
|
||||
handle: string
|
||||
|
|
|
@ -3,10 +3,12 @@ import {observer} from 'mobx-react-lite'
|
|||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
NativeSyntheticEvent,
|
||||
Platform,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TextInputSelectionChangeEventData,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
|
@ -16,8 +18,9 @@ import {
|
|||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
// import {useAnalytics} from '@segment/analytics-react-native' TODO
|
||||
import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import _isEqual from 'lodash.isequal'
|
||||
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
|
||||
import {Autocomplete} from './Autocomplete'
|
||||
import {ExternalEmbed} from './ExternalEmbed'
|
||||
import {Text} from '../util/text/Text'
|
||||
|
@ -26,24 +29,27 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
|
|||
import {CharProgress} from './char-progress/CharProgress'
|
||||
import {TextLink} from '../util/Link'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {useStores} from '../../../state'
|
||||
import * as apilib from '../../../state/lib/api'
|
||||
import {ComposerOpts} from '../../../state/models/shell-ui'
|
||||
import {s, colors, gradients} from '../../lib/styles'
|
||||
import {
|
||||
detectLinkables,
|
||||
extractEntities,
|
||||
cleanError,
|
||||
} from '../../../lib/strings'
|
||||
import {getLinkMeta} from '../../../lib/link-meta'
|
||||
import {downloadAndResize} from '../../../lib/images'
|
||||
import {useStores} from 'state/index'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {ComposerOpts} from 'state/models/shell-ui'
|
||||
import {s, colors, gradients} from 'lib/styles'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection'
|
||||
import {getLinkMeta} from 'lib/link-meta/link-meta'
|
||||
import {downloadAndResize} from 'lib/images'
|
||||
import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker'
|
||||
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
|
||||
import {SelectedPhoto} from './SelectedPhoto'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
const MAX_TEXT_LENGTH = 256
|
||||
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
||||
|
||||
interface Selection {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export const ComposePost = observer(function ComposePost({
|
||||
replyTo,
|
||||
imagesOpen,
|
||||
|
@ -55,10 +61,11 @@ export const ComposePost = observer(function ComposePost({
|
|||
onPost?: ComposerOpts['onPost']
|
||||
onClose: () => void
|
||||
}) {
|
||||
// const {track} = useAnalytics() TODO
|
||||
const {track, screen} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const textInput = useRef<TextInputRef>(null)
|
||||
const textInputSelection = useRef<Selection>({start: 0, end: 0})
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [processingState, setProcessingState] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
@ -66,7 +73,9 @@ export const ComposePost = observer(function ComposePost({
|
|||
const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const [attemptedExtLinks, setAttemptedExtLinks] = useState<string[]>([])
|
||||
const [suggestedExtLinks, setSuggestedExtLinks] = useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [isSelectingPhotos, setIsSelectingPhotos] = useState(
|
||||
imagesOpen || false,
|
||||
)
|
||||
|
@ -117,10 +126,10 @@ export const ComposePost = observer(function ComposePost({
|
|||
if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
|
||||
downloadAndResize({
|
||||
uri: extLink.meta.image,
|
||||
width: 250,
|
||||
height: 250,
|
||||
width: 2000,
|
||||
height: 2000,
|
||||
mode: 'contain',
|
||||
maxSize: 100000,
|
||||
maxSize: 1000000,
|
||||
timeout: 15e3,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
@ -166,6 +175,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
textInput.current?.focus()
|
||||
}
|
||||
const onPressSelectPhotos = () => {
|
||||
track('ComposePost:SelectPhotos')
|
||||
if (isSelectingPhotos) {
|
||||
setIsSelectingPhotos(false)
|
||||
} else if (selectedPhotos.length < 4) {
|
||||
|
@ -173,35 +183,31 @@ export const ComposePost = observer(function ComposePost({
|
|||
}
|
||||
}
|
||||
const onSelectPhotos = (photos: string[]) => {
|
||||
track('ComposePost:SelectPhotos:Done')
|
||||
setSelectedPhotos(photos)
|
||||
if (photos.length >= 4) {
|
||||
setIsSelectingPhotos(false)
|
||||
}
|
||||
}
|
||||
const onPressAddLinkCard = (uri: string) => {
|
||||
setExtLink({uri, isLoading: true})
|
||||
}
|
||||
const onChangeText = (newText: string) => {
|
||||
setText(newText)
|
||||
|
||||
const prefix = extractTextAutocompletePrefix(newText)
|
||||
if (typeof prefix === 'string') {
|
||||
const prefix = getMentionAt(newText, textInputSelection.current?.start || 0)
|
||||
if (prefix) {
|
||||
autocompleteView.setActive(true)
|
||||
autocompleteView.setPrefix(prefix)
|
||||
autocompleteView.setPrefix(prefix.value)
|
||||
} else {
|
||||
autocompleteView.setActive(false)
|
||||
}
|
||||
|
||||
if (!extLink && /\s$/.test(newText)) {
|
||||
const ents = extractEntities(newText)
|
||||
const entLink = ents
|
||||
?.filter(
|
||||
ent => ent.type === 'link' && !attemptedExtLinks.includes(ent.value),
|
||||
)
|
||||
.pop() // use last
|
||||
if (entLink) {
|
||||
setExtLink({
|
||||
uri: entLink.value,
|
||||
isLoading: true,
|
||||
})
|
||||
setAttemptedExtLinks([...attemptedExtLinks, entLink.value])
|
||||
if (!extLink) {
|
||||
const ents = extractEntities(newText)?.filter(ent => ent.type === 'link')
|
||||
const set = new Set(ents ? ents.map(e => e.value) : [])
|
||||
if (!_isEqual(set, suggestedExtLinks)) {
|
||||
setSuggestedExtLinks(set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -218,6 +224,16 @@ export const ComposePost = observer(function ComposePost({
|
|||
onSelectPhotos([...selectedPhotos, finalImgPath])
|
||||
}
|
||||
}
|
||||
const onSelectionChange = (
|
||||
evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>,
|
||||
) => {
|
||||
// NOTE we track the input selection using a ref to avoid excessive renders -prf
|
||||
textInputSelection.current = evt.nativeEvent.selection
|
||||
}
|
||||
const onSelectAutocompleteItem = (item: string) => {
|
||||
setText(insertMentionAt(text, textInputSelection.current?.start || 0, item))
|
||||
autocompleteView.setActive(false)
|
||||
}
|
||||
const onPressCancel = () => hackfixOnClose()
|
||||
const onPressPublish = async () => {
|
||||
if (isProcessing) {
|
||||
|
@ -242,11 +258,15 @@ export const ComposePost = observer(function ComposePost({
|
|||
autocompleteView.knownHandles,
|
||||
setProcessingState,
|
||||
)
|
||||
// TODO
|
||||
// track('Create Post', {
|
||||
// imageCount: selectedPhotos.length,
|
||||
// })
|
||||
track('Create Post', {
|
||||
imageCount: selectedPhotos.length,
|
||||
})
|
||||
} catch (e: any) {
|
||||
setExtLink({
|
||||
...extLink,
|
||||
isLoading: true,
|
||||
localThumb: undefined,
|
||||
} as apilib.ExternalEmbedDraft)
|
||||
setError(cleanError(e.message))
|
||||
setIsProcessing(false)
|
||||
return
|
||||
|
@ -256,10 +276,6 @@ export const ComposePost = observer(function ComposePost({
|
|||
hackfixOnClose()
|
||||
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
|
||||
}
|
||||
const onSelectAutocompleteItem = (item: string) => {
|
||||
setText(replaceTextAutocompletePrefix(text, item))
|
||||
autocompleteView.setActive(false)
|
||||
}
|
||||
|
||||
const canPost = text.length <= MAX_TEXT_LENGTH
|
||||
|
||||
|
@ -386,6 +402,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
innerRef={textInput}
|
||||
onChangeText={(str: string) => onChangeText(str)}
|
||||
onPaste={onPaste}
|
||||
onSelectionChange={onSelectionChange}
|
||||
placeholder={selectTextInputPlaceholder}
|
||||
style={[
|
||||
pal.text,
|
||||
|
@ -406,12 +423,27 @@ export const ComposePost = observer(function ComposePost({
|
|||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
{isSelectingPhotos && selectedPhotos.length < 4 && (
|
||||
{isSelectingPhotos && selectedPhotos.length < 4 ? (
|
||||
<PhotoCarouselPicker
|
||||
selectedPhotos={selectedPhotos}
|
||||
onSelectPhotos={onSelectPhotos}
|
||||
/>
|
||||
)}
|
||||
) : !extLink &&
|
||||
selectedPhotos.length === 0 &&
|
||||
suggestedExtLinks.size > 0 ? (
|
||||
<View style={s.mb5}>
|
||||
{Array.from(suggestedExtLinks).map(url => (
|
||||
<TouchableOpacity
|
||||
key={`suggested-${url}`}
|
||||
style={[pal.borderDark, styles.addExtLinkBtn]}
|
||||
onPress={() => onPressAddLinkCard(url)}>
|
||||
<Text>
|
||||
Add link card: <Text style={pal.link}>{url}</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[pal.border, styles.bottomBar]}>
|
||||
<TouchableOpacity
|
||||
testID="composerSelectPhotosButton"
|
||||
|
@ -442,18 +474,6 @@ export const ComposePost = observer(function ComposePost({
|
|||
)
|
||||
})
|
||||
|
||||
const atPrefixRegex = /@([a-z0-9.]*)$/i
|
||||
function extractTextAutocompletePrefix(text: string) {
|
||||
const match = atPrefixRegex.exec(text)
|
||||
if (match) {
|
||||
return match[1]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
function replaceTextAutocompletePrefix(text: string, item: string) {
|
||||
return text.replace(atPrefixRegex, `@${item} `)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
flexDirection: 'column',
|
||||
|
@ -532,6 +552,13 @@ const styles = StyleSheet.create({
|
|||
paddingLeft: 13,
|
||||
paddingRight: 8,
|
||||
},
|
||||
addExtLinkBtn: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
bottomBar: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 10,
|
||||
|
|
|
@ -7,12 +7,11 @@ import {
|
|||
} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {BlurView} from '../util/BlurView'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {AutoSizedImage} from '../util/images/AutoSizedImage'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {s, gradients} from '../../lib/styles'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {ExternalEmbedDraft} from '../../../state/lib/api'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {ExternalEmbedDraft} from 'lib/api/index'
|
||||
|
||||
export const ExternalEmbed = ({
|
||||
link,
|
||||
|
@ -30,31 +29,12 @@ export const ExternalEmbed = ({
|
|||
<View style={[styles.outer, pal.view, pal.border]}>
|
||||
{link.isLoading ? (
|
||||
<View
|
||||
style={[
|
||||
styles.image,
|
||||
styles.imageFallback,
|
||||
{backgroundColor: pal.colors.backgroundLight},
|
||||
]}>
|
||||
style={[styles.image, {backgroundColor: pal.colors.backgroundLight}]}>
|
||||
<ActivityIndicator size="large" style={styles.spinner} />
|
||||
</View>
|
||||
) : link.localThumb ? (
|
||||
<AutoSizedImage
|
||||
uri={link.localThumb.path}
|
||||
containerStyle={styles.image}
|
||||
/>
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={[gradients.blueDark.start, gradients.blueDark.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[styles.image, styles.imageFallback]}
|
||||
/>
|
||||
)}
|
||||
<TouchableWithoutFeedback onPress={onRemove}>
|
||||
<BlurView style={styles.removeBtn} blurType="dark">
|
||||
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
|
||||
</BlurView>
|
||||
</TouchableWithoutFeedback>
|
||||
<AutoSizedImage uri={link.localThumb.path} style={styles.image} />
|
||||
) : undefined}
|
||||
<View style={styles.inner}>
|
||||
{!!link.meta?.title && (
|
||||
<Text type="sm-bold" numberOfLines={2} style={[pal.text]}>
|
||||
|
@ -81,6 +61,11 @@ export const ExternalEmbed = ({
|
|||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableWithoutFeedback onPress={onRemove}>
|
||||
<BlurView style={styles.removeBtn} blurType="dark">
|
||||
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
|
||||
</BlurView>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
@ -98,10 +83,7 @@ const styles = StyleSheet.create({
|
|||
borderTopLeftRadius: 6,
|
||||
borderTopRightRadius: 6,
|
||||
width: '100%',
|
||||
height: 200,
|
||||
},
|
||||
imageFallback: {
|
||||
height: 160,
|
||||
maxHeight: 200,
|
||||
},
|
||||
removeBtn: {
|
||||
position: 'absolute',
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react'
|
|||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export function ComposePrompt({
|
||||
text = "What's up?",
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import React, {useCallback} from 'react'
|
||||
import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {colors} from '../../lib/styles'
|
||||
import Image from 'view/com/util/images/Image'
|
||||
import {colors} from 'lib/styles'
|
||||
|
||||
export const SelectedPhoto = ({
|
||||
selectedPhotos,
|
||||
|
|
|
@ -5,7 +5,7 @@ import {Text} from '../../util/text/Text'
|
|||
import ProgressCircle from 'react-native-progress/Circle'
|
||||
// @ts-ignore no type definition -prf
|
||||
import ProgressPie from 'react-native-progress/Pie'
|
||||
import {s, colors} from '../../../lib/styles'
|
||||
import {s, colors} from 'lib/styles'
|
||||
|
||||
const MAX_TEXT_LENGTH = 256
|
||||
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Text} from '../../util/text/Text'
|
||||
import {s} from '../../../lib/styles'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
const MAX_TEXT_LENGTH = 256
|
||||
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {
|
||||
openPicker,
|
||||
openCamera,
|
||||
|
@ -12,18 +13,26 @@ import {
|
|||
import {
|
||||
UserLocalPhotosModel,
|
||||
PhotoIdentifier,
|
||||
} from '../../../../state/models/user-local-photos'
|
||||
import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
|
||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||
import {useStores, RootStoreModel} from '../../../../state'
|
||||
} from 'state/models/user-local-photos'
|
||||
import {
|
||||
compressIfNeeded,
|
||||
moveToPremanantPath,
|
||||
scaleDownDimensions,
|
||||
} from 'lib/images'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores, RootStoreModel} from 'state/index'
|
||||
import {
|
||||
requestPhotoAccessIfNeeded,
|
||||
requestCameraAccessIfNeeded,
|
||||
} from 'lib/permissions'
|
||||
|
||||
const MAX_WIDTH = 1000
|
||||
const MAX_HEIGHT = 1000
|
||||
const MAX_SIZE = 300000
|
||||
const MAX_WIDTH = 2000
|
||||
const MAX_HEIGHT = 2000
|
||||
const MAX_SIZE = 1000000
|
||||
|
||||
const IMAGE_PARAMS = {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
width: 2000,
|
||||
height: 2000,
|
||||
freeStyleCropEnabled: true,
|
||||
}
|
||||
|
||||
|
@ -46,8 +55,10 @@ export async function cropPhoto(
|
|||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
const img = await compressIfNeeded(cropperRes, MAX_SIZE)
|
||||
return img.path
|
||||
const permanentPath = await moveToPremanantPath(img.path)
|
||||
return permanentPath
|
||||
}
|
||||
|
||||
export const PhotoCarouselPicker = ({
|
||||
|
@ -57,24 +68,28 @@ export const PhotoCarouselPicker = ({
|
|||
selectedPhotos: string[]
|
||||
onSelectPhotos: (v: string[]) => void
|
||||
}) => {
|
||||
const {track} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const [localPhotos, setLocalPhotos] = React.useState<
|
||||
UserLocalPhotosModel | undefined
|
||||
>(undefined)
|
||||
const [isSetup, setIsSetup] = React.useState<boolean>(false)
|
||||
|
||||
const localPhotos = React.useMemo<UserLocalPhotosModel>(
|
||||
() => new UserLocalPhotosModel(store),
|
||||
[store],
|
||||
)
|
||||
|
||||
// initial setup
|
||||
React.useEffect(() => {
|
||||
const photos = new UserLocalPhotosModel(store)
|
||||
photos.setup().then(() => {
|
||||
if (photos.photos) {
|
||||
setLocalPhotos(photos)
|
||||
}
|
||||
// initial setup
|
||||
localPhotos.setup().then(() => {
|
||||
setIsSetup(true)
|
||||
})
|
||||
}, [store])
|
||||
}, [localPhotos])
|
||||
|
||||
const handleOpenCamera = useCallback(async () => {
|
||||
try {
|
||||
if (!(await requestCameraAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
const cameraRes = await openCamera(store, {
|
||||
mediaType: 'photo',
|
||||
...IMAGE_PARAMS,
|
||||
|
@ -89,6 +104,7 @@ export const PhotoCarouselPicker = ({
|
|||
|
||||
const handleSelectPhoto = useCallback(
|
||||
async (item: PhotoIdentifier) => {
|
||||
track('PhotoCarouselPicker:PhotoSelected')
|
||||
try {
|
||||
const imgPath = await cropPhoto(
|
||||
store,
|
||||
|
@ -102,37 +118,41 @@ export const PhotoCarouselPicker = ({
|
|||
store.log.warn('Error selecting photo', err)
|
||||
}
|
||||
},
|
||||
[store, selectedPhotos, onSelectPhotos],
|
||||
[track, store, onSelectPhotos, selectedPhotos],
|
||||
)
|
||||
|
||||
const handleOpenGallery = useCallback(() => {
|
||||
openPicker(store, {
|
||||
const handleOpenGallery = useCallback(async () => {
|
||||
track('PhotoCarouselPicker:GalleryOpened')
|
||||
if (!(await requestPhotoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
const items = await openPicker(store, {
|
||||
multiple: true,
|
||||
maxFiles: 4 - selectedPhotos.length,
|
||||
mediaType: 'photo',
|
||||
}).then(async items => {
|
||||
const result = []
|
||||
|
||||
for (const image of items) {
|
||||
// choose target dimensions based on the original
|
||||
// this causes the photo cropper to start with the full image "selected"
|
||||
const {width, height} = scaleDownDimensions(
|
||||
{width: image.width, height: image.height},
|
||||
{width: MAX_WIDTH, height: MAX_HEIGHT},
|
||||
)
|
||||
const cropperRes = await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path: image.path,
|
||||
freeStyleCropEnabled: true,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
|
||||
result.push(finalImg.path)
|
||||
}
|
||||
onSelectPhotos([...selectedPhotos, ...result])
|
||||
})
|
||||
}, [store, selectedPhotos, onSelectPhotos])
|
||||
const result = []
|
||||
|
||||
for (const image of items) {
|
||||
// choose target dimensions based on the original
|
||||
// this causes the photo cropper to start with the full image "selected"
|
||||
const {width, height} = scaleDownDimensions(
|
||||
{width: image.width, height: image.height},
|
||||
{width: MAX_WIDTH, height: MAX_HEIGHT},
|
||||
)
|
||||
const cropperRes = await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path: image.path,
|
||||
...IMAGE_PARAMS,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
|
||||
const permanentPath = await moveToPremanantPath(finalImg.path)
|
||||
result.push(permanentPath)
|
||||
}
|
||||
onSelectPhotos([...selectedPhotos, ...result])
|
||||
}, [track, store, selectedPhotos, onSelectPhotos])
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
|
@ -161,7 +181,7 @@ export const PhotoCarouselPicker = ({
|
|||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{localPhotos != null &&
|
||||
{isSetup &&
|
||||
localPhotos.photos.map((item: PhotoIdentifier, index: number) => (
|
||||
<TouchableOpacity
|
||||
testID="openSelectPhotoButton"
|
||||
|
|
|
@ -9,9 +9,9 @@ import {
|
|||
openCamera,
|
||||
openCropper,
|
||||
} from '../../util/images/image-crop-picker/ImageCropPicker'
|
||||
import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
|
||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||
import {useStores, RootStoreModel} from '../../../../state'
|
||||
import {compressIfNeeded, scaleDownDimensions} from 'lib/images'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores, RootStoreModel} from 'state/index'
|
||||
|
||||
const MAX_WIDTH = 1000
|
||||
const MAX_HEIGHT = 1000
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
import React from 'react'
|
||||
import {StyleProp, TextStyle} from 'react-native'
|
||||
import {
|
||||
NativeSyntheticEvent,
|
||||
StyleProp,
|
||||
TextInputSelectionChangeEventData,
|
||||
TextStyle,
|
||||
} from 'react-native'
|
||||
import PasteInput, {
|
||||
PastedFile,
|
||||
PasteInputRef,
|
||||
} from '@mattermost/react-native-paste-input'
|
||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export type TextInputRef = PasteInputRef
|
||||
|
||||
|
@ -14,6 +19,9 @@ interface TextInputProps {
|
|||
placeholder: string
|
||||
style: StyleProp<TextStyle>
|
||||
onChangeText: (str: string) => void
|
||||
onSelectionChange?:
|
||||
| ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void)
|
||||
| undefined
|
||||
onPaste: (err: string | undefined, uris: string[]) => void
|
||||
}
|
||||
|
||||
|
@ -23,6 +31,7 @@ export function TextInput({
|
|||
placeholder,
|
||||
style,
|
||||
onChangeText,
|
||||
onSelectionChange,
|
||||
onPaste,
|
||||
children,
|
||||
}: React.PropsWithChildren<TextInputProps>) {
|
||||
|
@ -44,6 +53,7 @@ export function TextInput({
|
|||
multiline
|
||||
scrollEnabled
|
||||
onChangeText={(str: string) => onChangeText(str)}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onPaste={onPasteInner}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
NativeSyntheticEvent,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
TextInput as RNTextInput,
|
||||
TextInputSelectionChangeEventData,
|
||||
TextStyle,
|
||||
} from 'react-native'
|
||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||
import {addStyle} from '../../../lib/addStyle'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {addStyle} from 'lib/styles'
|
||||
|
||||
export type TextInputRef = RNTextInput
|
||||
|
||||
|
@ -16,6 +18,9 @@ interface TextInputProps {
|
|||
placeholder: string
|
||||
style: StyleProp<TextStyle>
|
||||
onChangeText: (str: string) => void
|
||||
onSelectionChange?:
|
||||
| ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void)
|
||||
| undefined
|
||||
onPaste: (err: string | undefined, uris: string[]) => void
|
||||
}
|
||||
|
||||
|
@ -25,6 +30,7 @@ export function TextInput({
|
|||
placeholder,
|
||||
style,
|
||||
onChangeText,
|
||||
onSelectionChange,
|
||||
children,
|
||||
}: React.PropsWithChildren<TextInputProps>) {
|
||||
const pal = usePalette('default')
|
||||
|
@ -36,6 +42,7 @@ export function TextInput({
|
|||
multiline
|
||||
scrollEnabled
|
||||
onChangeText={(str: string) => onChangeText(str)}
|
||||
onSelectionChange={onSelectionChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
style={style}>
|
||||
|
|
|
@ -8,19 +8,18 @@ import {
|
|||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import _omit from 'lodash.omit'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {useStores} from '../../../state'
|
||||
import * as apilib from '../../../state/lib/api'
|
||||
import {useStores} from 'state/index'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {
|
||||
SuggestedActorsViewModel,
|
||||
SuggestedActor,
|
||||
} from '../../../state/models/suggested-actors-view'
|
||||
import {s, gradients} from '../../lib/styles'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
} from 'state/models/suggested-actors-view'
|
||||
import {s, gradients} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export const LiteSuggestedFollows = observer(() => {
|
||||
const store = useStores()
|
||||
|
|
|
@ -1,51 +1,28 @@
|
|||
import React, {useEffect, useState} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import _omit from 'lodash.omit'
|
||||
import React from 'react'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {CenteredView, FlatList} from '../util/Views'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {ErrorScreen} from '../util/error/ErrorScreen'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {useStores} from '../../../state'
|
||||
import * as apilib from '../../../state/lib/api'
|
||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||
import {useStores} from 'state/index'
|
||||
import {
|
||||
SuggestedActorsViewModel,
|
||||
SuggestedActor,
|
||||
} from '../../../state/models/suggested-actors-view'
|
||||
import {s, gradients} from '../../lib/styles'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
} from 'state/models/suggested-actors-view'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export const SuggestedFollows = observer(
|
||||
({
|
||||
onNoSuggestions,
|
||||
asLinks,
|
||||
}: {
|
||||
onNoSuggestions?: () => void
|
||||
asLinks?: boolean
|
||||
}) => {
|
||||
({onNoSuggestions}: {onNoSuggestions?: () => void}) => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const [follows, setFollows] = useState<Record<string, string>>({})
|
||||
|
||||
// Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
|
||||
const view = React.useMemo<SuggestedActorsViewModel>(
|
||||
() => new SuggestedActorsViewModel(store),
|
||||
[store],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
view
|
||||
.loadMore()
|
||||
.catch((err: any) =>
|
||||
|
@ -53,7 +30,7 @@ export const SuggestedFollows = observer(
|
|||
)
|
||||
}, [view, store.log])
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (!view.isLoading && !view.hasError && !view.hasContent) {
|
||||
onNoSuggestions?.()
|
||||
}
|
||||
|
@ -74,46 +51,16 @@ export const SuggestedFollows = observer(
|
|||
)
|
||||
}
|
||||
|
||||
const onPressFollow = async (item: SuggestedActor) => {
|
||||
try {
|
||||
const res = await apilib.follow(store, item.did, item.declaration.cid)
|
||||
setFollows({[item.did]: res.uri, ...follows})
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed fo create follow', e)
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
}
|
||||
}
|
||||
const onPressUnfollow = async (item: SuggestedActor) => {
|
||||
try {
|
||||
await apilib.unfollow(store, follows[item.did])
|
||||
setFollows(_omit(follows, [item.did]))
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed fo delete follow', e)
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
const renderItem = ({item}: {item: SuggestedActor}) => {
|
||||
if (asLinks) {
|
||||
return (
|
||||
<Link
|
||||
href={`/profile/${item.handle}`}
|
||||
title={item.displayName || item.handle}>
|
||||
<User
|
||||
item={item}
|
||||
follow={follows[item.did]}
|
||||
onPressFollow={onPressFollow}
|
||||
onPressUnfollow={onPressUnfollow}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<User
|
||||
item={item}
|
||||
follow={follows[item.did]}
|
||||
onPressFollow={onPressFollow}
|
||||
onPressUnfollow={onPressUnfollow}
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.did}
|
||||
did={item.did}
|
||||
declarationCid={item.declaration.cid}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
description={item.description}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -146,7 +93,6 @@ export const SuggestedFollows = observer(
|
|||
</View>
|
||||
)}
|
||||
contentContainerStyle={s.contentContainer}
|
||||
style={s.flex1}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
@ -155,128 +101,16 @@ export const SuggestedFollows = observer(
|
|||
},
|
||||
)
|
||||
|
||||
const User = ({
|
||||
item,
|
||||
follow,
|
||||
onPressFollow,
|
||||
onPressUnfollow,
|
||||
}: {
|
||||
item: SuggestedActor
|
||||
follow: string | undefined
|
||||
onPressFollow: (item: SuggestedActor) => void
|
||||
onPressUnfollow: (item: SuggestedActor) => void
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View style={[styles.actor, pal.view, pal.border]}>
|
||||
<View style={styles.actorMeta}>
|
||||
<View style={styles.actorAvi}>
|
||||
<UserAvatar
|
||||
size={40}
|
||||
displayName={item.displayName}
|
||||
handle={item.handle}
|
||||
avatar={item.avatar}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.actorContent}>
|
||||
<Text type="title-sm" style={pal.text} numberOfLines={1}>
|
||||
{item.displayName || item.handle}
|
||||
</Text>
|
||||
<Text style={pal.textLight} numberOfLines={1}>
|
||||
@{item.handle}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actorBtn}>
|
||||
{follow ? (
|
||||
<TouchableOpacity onPress={() => onPressUnfollow(item)}>
|
||||
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
|
||||
<Text type="button" style={pal.text}>
|
||||
Unfollow
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity onPress={() => onPressFollow(item)}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[styles.btn, styles.gradientBtn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="plus"
|
||||
style={[s.white as FontAwesomeIconStyle, s.mr5]}
|
||||
size={15}
|
||||
/>
|
||||
<Text style={[s.white, s.fw600, s.f15]}>Follow</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{item.description ? (
|
||||
<View style={styles.actorDetails}>
|
||||
<Text style={pal.text} numberOfLines={4}>
|
||||
{item.description}
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
},
|
||||
|
||||
suggestionsContainer: {
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
},
|
||||
footer: {
|
||||
height: 200,
|
||||
paddingTop: 20,
|
||||
},
|
||||
|
||||
actor: {
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
actorMeta: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
actorAvi: {
|
||||
width: 60,
|
||||
paddingLeft: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
actorContent: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
paddingTop: 10,
|
||||
},
|
||||
actorBtn: {
|
||||
paddingRight: 10,
|
||||
paddingTop: 10,
|
||||
},
|
||||
actorDetails: {
|
||||
paddingLeft: 60,
|
||||
paddingRight: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
|
||||
gradientBtn: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
secondaryBtn: {
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 7,
|
||||
borderRadius: 50,
|
||||
marginLeft: 6,
|
||||
},
|
||||
})
|
||||
|
|
66
src/view/com/discover/SuggestedPosts.tsx
Normal file
66
src/view/com/discover/SuggestedPosts.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
import React from 'react'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {useStores} from 'state/index'
|
||||
import {SuggestedPostsView} from 'state/models/suggested-posts-view'
|
||||
import {s} from 'lib/styles'
|
||||
import {FeedItem as Post} from '../posts/FeedItem'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export const SuggestedPosts = observer(() => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const suggestedPostsView = React.useMemo<SuggestedPostsView>(
|
||||
() => new SuggestedPostsView(store),
|
||||
[store],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!suggestedPostsView.hasLoaded) {
|
||||
suggestedPostsView.setup()
|
||||
}
|
||||
}, [store, suggestedPostsView])
|
||||
|
||||
return (
|
||||
<>
|
||||
{(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && (
|
||||
<Text type="title" style={[styles.heading, pal.text]}>
|
||||
Recently, on Bluesky...
|
||||
</Text>
|
||||
)}
|
||||
{suggestedPostsView.hasContent && (
|
||||
<>
|
||||
<View style={[pal.border, styles.bottomBorder]}>
|
||||
{suggestedPostsView.posts.map(item => (
|
||||
<Post item={item} key={item._reactKey} />
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{suggestedPostsView.isLoading && (
|
||||
<View style={s.mt10}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
fontWeight: 'bold',
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
|
||||
bottomBorder: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
|
||||
loadMore: {
|
||||
paddingLeft: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
})
|
89
src/view/com/discover/WhoToFollow.tsx
Normal file
89
src/view/com/discover/WhoToFollow.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {useStores} from 'state/index'
|
||||
import {SuggestedActorsViewModel} from 'state/models/suggested-actors-view'
|
||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export const WhoToFollow = observer(() => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const suggestedActorsView = React.useMemo<SuggestedActorsViewModel>(
|
||||
() => new SuggestedActorsViewModel(store, {pageSize: 5}),
|
||||
[store],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
suggestedActorsView.loadMore(true)
|
||||
}, [store, suggestedActorsView])
|
||||
|
||||
const onPressLoadMoreSuggestedActors = () => {
|
||||
suggestedActorsView.loadMore()
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && (
|
||||
<Text type="title" style={[styles.heading, pal.text]}>
|
||||
Who to follow
|
||||
</Text>
|
||||
)}
|
||||
{suggestedActorsView.hasContent && (
|
||||
<>
|
||||
<View style={[pal.border, styles.bottomBorder]}>
|
||||
{suggestedActorsView.suggestions.map(item => (
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.did}
|
||||
did={item.did}
|
||||
declarationCid={item.declaration.cid}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
description={item.description}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{!suggestedActorsView.isLoading && suggestedActorsView.hasMore && (
|
||||
<TouchableOpacity
|
||||
onPress={onPressLoadMoreSuggestedActors}
|
||||
style={styles.loadMore}>
|
||||
<Text type="lg" style={pal.link}>
|
||||
Show more
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{suggestedActorsView.isLoading && (
|
||||
<View style={s.mt10}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
fontWeight: 'bold',
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
|
||||
bottomBorder: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
|
||||
loadMore: {
|
||||
paddingLeft: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
})
|
|
@ -86,12 +86,18 @@ function ImageViewing({
|
|||
[toggleBarsVisible],
|
||||
)
|
||||
|
||||
const onLayout = useCallback(() => {
|
||||
if (imageIndex) {
|
||||
imageList.current?.scrollToIndex({index: imageIndex, animated: false})
|
||||
}
|
||||
}, [imageList, imageIndex])
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.screen} onLayout={onLayout}>
|
||||
<Modal />
|
||||
<View style={[styles.container, {opacity, backgroundColor}]}>
|
||||
<Animated.View style={[styles.header, {transform: headerTransform}]}>
|
||||
|
@ -108,12 +114,8 @@ function ImageViewing({
|
|||
data={images}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
windowSize={2}
|
||||
initialNumToRender={1}
|
||||
maxToRenderPerBatch={1}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
initialScrollIndex={imageIndex}
|
||||
getItem={(_, index) => images[index]}
|
||||
getItemCount={() => images.length}
|
||||
getItemLayout={(_, index) => ({
|
||||
|
|
|
@ -2,9 +2,9 @@ import React from 'react'
|
|||
import {View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import ImageView from './ImageViewing'
|
||||
import {useStores} from '../../../state'
|
||||
import * as models from '../../../state/models/shell-ui'
|
||||
import {saveImageModal} from '../../../lib/images'
|
||||
import {useStores} from 'state/index'
|
||||
import * as models from 'state/models/shell-ui'
|
||||
import {saveImageModal} from 'lib/images'
|
||||
import {ImageSource} from './ImageViewing/@types'
|
||||
|
||||
export const Lightbox = observer(function Lightbox() {
|
||||
|
|
|
@ -8,9 +8,9 @@ import {
|
|||
} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {useStores} from '../../../state'
|
||||
import * as models from '../../../state/models/shell-ui'
|
||||
import {colors} from '../../lib/styles'
|
||||
import {useStores} from 'state/index'
|
||||
import * as models from 'state/models/shell-ui'
|
||||
import {colors} from 'lib/styles'
|
||||
|
||||
interface Img {
|
||||
uri: string
|
||||
|
|
|
@ -15,24 +15,22 @@ import {
|
|||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {ComAtprotoAccountCreate} from '@atproto/api'
|
||||
import * as EmailValidator from 'email-validator'
|
||||
// import {useAnalytics} from '@segment/analytics-react-native' TODO
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {LogoTextHero} from './Logo'
|
||||
import {Picker} from '../util/Picker'
|
||||
import {TextLink} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {s, colors} from '../../lib/styles'
|
||||
import {
|
||||
makeValidHandle,
|
||||
createFullHandle,
|
||||
toNiceDomain,
|
||||
} from '../../../lib/strings'
|
||||
import {useStores, DEFAULT_SERVICE} from '../../../state'
|
||||
import {ServiceDescription} from '../../../state/models/session'
|
||||
import {ServerInputModal} from '../../../state/models/shell-ui'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {makeValidHandle, createFullHandle} from 'lib/strings/handles'
|
||||
import {toNiceDomain} from 'lib/strings/url-helpers'
|
||||
import {useStores, DEFAULT_SERVICE} from 'state/index'
|
||||
import {ServiceDescription} from 'state/models/session'
|
||||
import {ServerInputModal} from 'state/models/shell-ui'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
||||
export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
||||
// const {track} = useAnalytics() TODO
|
||||
const {track, screen} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||
|
@ -49,6 +47,10 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
|||
const [handle, setHandle] = useState<string>('')
|
||||
const [is13, setIs13] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
screen('CreateAccount')
|
||||
}, [screen])
|
||||
|
||||
useEffect(() => {
|
||||
let aborted = false
|
||||
setError('')
|
||||
|
@ -109,7 +111,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
|||
password,
|
||||
inviteCode,
|
||||
})
|
||||
// track('Create Account') TODO
|
||||
track('Create Account')
|
||||
} catch (e: any) {
|
||||
let errMsg = e.toString()
|
||||
if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) {
|
||||
|
@ -118,7 +120,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
|||
}
|
||||
store.log.error('Failed to create account', e)
|
||||
setIsProcessing(false)
|
||||
setError(errMsg.replace(/^Error:/, ''))
|
||||
setError(cleanError(errMsg))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {s, gradients} from '../../lib/styles'
|
||||
import {s, gradients} from 'lib/styles'
|
||||
import {Text} from '../util/text/Text'
|
||||
|
||||
export const LogoTextHero = () => {
|
||||
|
|
|
@ -13,19 +13,21 @@ import {
|
|||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import * as EmailValidator from 'email-validator'
|
||||
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
|
||||
// import {useAnalytics} from '@segment/analytics-react-native' TODO
|
||||
import AtpAgent from '@atproto/api'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {LogoTextHero} from './Logo'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {s, colors} from '../../lib/styles'
|
||||
import {createFullHandle, toNiceDomain} from '../../../lib/strings'
|
||||
import {useStores, RootStoreModel, DEFAULT_SERVICE} from '../../../state'
|
||||
import {ServiceDescription} from '../../../state/models/session'
|
||||
import {ServerInputModal} from '../../../state/models/shell-ui'
|
||||
import {AccountData} from '../../../state/models/session'
|
||||
import {isNetworkError} from '../../../lib/errors'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {createFullHandle} from 'lib/strings/handles'
|
||||
import {toNiceDomain} from 'lib/strings/url-helpers'
|
||||
import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index'
|
||||
import {ServiceDescription} from 'state/models/session'
|
||||
import {ServerInputModal} from 'state/models/shell-ui'
|
||||
import {AccountData} from 'state/models/session'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
||||
enum Forms {
|
||||
Login,
|
||||
|
@ -38,6 +40,7 @@ enum Forms {
|
|||
export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const {track} = useAnalytics()
|
||||
const [error, setError] = useState<string>('')
|
||||
const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({})
|
||||
const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE)
|
||||
|
@ -91,6 +94,10 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
|
|||
}, [store.session, store.log, serviceUrl, retryDescribeTrigger])
|
||||
|
||||
const onPressRetryConnect = () => setRetryDescribeTrigger({})
|
||||
const onPressForgotPassword = () => {
|
||||
track('Signin:PressedForgotPassword')
|
||||
setCurrentForm(Forms.ForgotPassword)
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView testID="signIn" behavior="padding" style={[pal.view]}>
|
||||
|
@ -104,7 +111,7 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
|
|||
setError={setError}
|
||||
setServiceUrl={setServiceUrl}
|
||||
onPressBack={onPressBack}
|
||||
onPressForgotPassword={gotoForm(Forms.ForgotPassword)}
|
||||
onPressForgotPassword={onPressForgotPassword}
|
||||
onPressRetryConnect={onPressRetryConnect}
|
||||
/>
|
||||
) : undefined}
|
||||
|
@ -153,15 +160,19 @@ const ChooseAccountForm = ({
|
|||
onSelectAccount: (account?: AccountData) => void
|
||||
onPressBack: () => void
|
||||
}) => {
|
||||
// const {track} = useAnalytics() TODO
|
||||
const {track, screen} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const [isProcessing, setIsProcessing] = React.useState(false)
|
||||
|
||||
// React.useEffect(() => {
|
||||
screen('Choose Account')
|
||||
// }, [screen])
|
||||
|
||||
const onTryAccount = async (account: AccountData) => {
|
||||
if (account.accessJwt && account.refreshJwt) {
|
||||
setIsProcessing(true)
|
||||
if (await store.session.resumeSession(account)) {
|
||||
// track('Sign In', {resumedSession: true}) TODO
|
||||
track('Sign In', {resumedSession: true})
|
||||
setIsProcessing(false)
|
||||
return
|
||||
}
|
||||
|
@ -261,15 +272,16 @@ const LoginForm = ({
|
|||
onPressBack: () => void
|
||||
onPressForgotPassword: () => void
|
||||
}) => {
|
||||
// const {track} = useAnalytics() TODO
|
||||
const {track} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||
const [handle, setHandle] = useState<string>(initialHandle)
|
||||
const [identifier, setIdentifier] = useState<string>(initialHandle)
|
||||
const [password, setPassword] = useState<string>('')
|
||||
|
||||
const onPressSelectService = () => {
|
||||
store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl))
|
||||
Keyboard.dismiss()
|
||||
track('Signin:PressedSelectService')
|
||||
}
|
||||
|
||||
const onPressNext = async () => {
|
||||
|
@ -278,20 +290,21 @@ const LoginForm = ({
|
|||
|
||||
try {
|
||||
// try to guess the handle if the user just gave their own username
|
||||
let fullHandle = handle
|
||||
let fullIdent = identifier
|
||||
if (
|
||||
!identifier.includes('@') && // not an email
|
||||
serviceDescription &&
|
||||
serviceDescription.availableUserDomains.length > 0
|
||||
) {
|
||||
let matched = false
|
||||
for (const domain of serviceDescription.availableUserDomains) {
|
||||
if (fullHandle.endsWith(domain)) {
|
||||
if (fullIdent.endsWith(domain)) {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
fullHandle = createFullHandle(
|
||||
handle,
|
||||
fullIdent = createFullHandle(
|
||||
identifier,
|
||||
serviceDescription.availableUserDomains[0],
|
||||
)
|
||||
}
|
||||
|
@ -299,10 +312,10 @@ const LoginForm = ({
|
|||
|
||||
await store.session.login({
|
||||
service: serviceUrl,
|
||||
handle: fullHandle,
|
||||
identifier: fullIdent,
|
||||
password,
|
||||
})
|
||||
// track('Sign In', {resumedSession: false}) TODO
|
||||
track('Sign In', {resumedSession: false})
|
||||
} catch (e: any) {
|
||||
const errMsg = e.toString()
|
||||
store.log.warn('Failed to login', e)
|
||||
|
@ -314,12 +327,12 @@ const LoginForm = ({
|
|||
'Unable to contact your service. Please check your Internet connection.',
|
||||
)
|
||||
} else {
|
||||
setError(errMsg.replace(/^Error:/, ''))
|
||||
setError(cleanError(errMsg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isReady = !!serviceDescription && !!handle && !!password
|
||||
const isReady = !!serviceDescription && !!identifier && !!password
|
||||
return (
|
||||
<View testID="loginForm">
|
||||
<LogoTextHero />
|
||||
|
@ -361,13 +374,13 @@ const LoginForm = ({
|
|||
<TextInput
|
||||
testID="loginUsernameInput"
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder="Username"
|
||||
placeholder="Username or email address"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
autoCorrect={false}
|
||||
value={handle}
|
||||
onChangeText={str => setHandle((str || '').toLowerCase())}
|
||||
value={identifier}
|
||||
onChangeText={str => setIdentifier((str || '').toLowerCase())}
|
||||
editable={!isProcessing}
|
||||
/>
|
||||
</View>
|
||||
|
@ -464,6 +477,11 @@ const ForgotPasswordForm = ({
|
|||
const pal = usePalette('default')
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const {screen} = useAnalytics()
|
||||
|
||||
// useEffect(() => {
|
||||
screen('Signin:ForgotPassword')
|
||||
// }, [screen])
|
||||
|
||||
const onPressSelectService = () => {
|
||||
store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl))
|
||||
|
@ -478,8 +496,8 @@ const ForgotPasswordForm = ({
|
|||
setIsProcessing(true)
|
||||
|
||||
try {
|
||||
const api = AtpApi.service(serviceUrl) as SessionServiceClient
|
||||
await api.com.atproto.account.requestPasswordReset({email})
|
||||
const agent = new AtpAgent({service: serviceUrl})
|
||||
await agent.api.com.atproto.account.requestPasswordReset({email})
|
||||
onEmailSent()
|
||||
} catch (e: any) {
|
||||
const errMsg = e.toString()
|
||||
|
@ -490,7 +508,7 @@ const ForgotPasswordForm = ({
|
|||
'Unable to contact your service. Please check your Internet connection.',
|
||||
)
|
||||
} else {
|
||||
setError(errMsg.replace(/^Error:/, ''))
|
||||
setError(cleanError(errMsg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -604,6 +622,12 @@ const SetNewPasswordForm = ({
|
|||
onPasswordSet: () => void
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const {screen} = useAnalytics()
|
||||
|
||||
// useEffect(() => {
|
||||
screen('Signin:SetNewPasswordForm')
|
||||
// }, [screen])
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||
const [resetCode, setResetCode] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
|
@ -613,8 +637,11 @@ const SetNewPasswordForm = ({
|
|||
setIsProcessing(true)
|
||||
|
||||
try {
|
||||
const api = AtpApi.service(serviceUrl) as SessionServiceClient
|
||||
await api.com.atproto.account.resetPassword({token: resetCode, password})
|
||||
const agent = new AtpAgent({service: serviceUrl})
|
||||
await agent.api.com.atproto.account.resetPassword({
|
||||
token: resetCode,
|
||||
password,
|
||||
})
|
||||
onPasswordSet()
|
||||
} catch (e: any) {
|
||||
const errMsg = e.toString()
|
||||
|
@ -625,7 +652,7 @@ const SetNewPasswordForm = ({
|
|||
'Unable to contact your service. Please check your Internet connection.',
|
||||
)
|
||||
} else {
|
||||
setError(errMsg.replace(/^Error:/, ''))
|
||||
setError(cleanError(errMsg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -726,6 +753,12 @@ const SetNewPasswordForm = ({
|
|||
}
|
||||
|
||||
const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => {
|
||||
const {screen} = useAnalytics()
|
||||
|
||||
// useEffect(() => {
|
||||
screen('Signin:PasswordUpdatedForm')
|
||||
// }, [screen])
|
||||
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -7,9 +7,10 @@ import {
|
|||
} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {useStores} from '../../../state'
|
||||
import {s, colors, gradients} from '../../lib/styles'
|
||||
import {useStores} from 'state/index'
|
||||
import {s, colors, gradients} from 'lib/styles'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
||||
export const snapPoints = ['50%']
|
||||
|
||||
|
@ -33,7 +34,7 @@ export function Component({
|
|||
store.shell.closeModal()
|
||||
return
|
||||
} catch (e: any) {
|
||||
setError(e.toString())
|
||||
setError(cleanError(e))
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
|
210
src/view/com/modals/DeleteAccount.tsx
Normal file
210
src/view/com/modals/DeleteAccount.tsx
Normal file
|
@ -0,0 +1,210 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {BottomSheetTextInput} from '@gorhom/bottom-sheet'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {useStores} from 'state/index'
|
||||
import {s, colors, gradients} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
||||
export const snapPoints = ['60%']
|
||||
|
||||
export function Component({}: {}) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false)
|
||||
const [confirmCode, setConfirmCode] = React.useState<string>('')
|
||||
const [password, setPassword] = React.useState<string>('')
|
||||
const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
|
||||
const [error, setError] = React.useState<string>('')
|
||||
const onPressSendEmail = async () => {
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await store.api.com.atproto.account.requestDelete()
|
||||
setIsEmailSent(true)
|
||||
} catch (e: any) {
|
||||
setError(cleanError(e))
|
||||
}
|
||||
setIsProcessing(false)
|
||||
}
|
||||
const onPressConfirmDelete = async () => {
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await store.api.com.atproto.account.delete({
|
||||
did: store.me.did,
|
||||
password,
|
||||
token: confirmCode,
|
||||
})
|
||||
Toast.show('Your account has been deleted')
|
||||
store.nav.tab.fixedTabReset()
|
||||
store.session.clear()
|
||||
store.shell.closeModal()
|
||||
} catch (e: any) {
|
||||
setError(cleanError(e))
|
||||
}
|
||||
setIsProcessing(false)
|
||||
}
|
||||
const onCancel = () => {
|
||||
store.shell.closeModal()
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={[styles.container, {backgroundColor: pal.colors.backgroundLight}]}>
|
||||
<View style={[styles.innerContainer, pal.view]}>
|
||||
<Text type="title-xl" style={[styles.title, pal.text]}>
|
||||
Delete account
|
||||
</Text>
|
||||
{!isEmailSent ? (
|
||||
<>
|
||||
<Text type="lg" style={[styles.description, pal.text]}>
|
||||
For security reasons, we'll need to send a confirmation code to
|
||||
your email.
|
||||
</Text>
|
||||
{error ? (
|
||||
<View style={s.mt10}>
|
||||
<ErrorMessage message={error} />
|
||||
</View>
|
||||
) : undefined}
|
||||
{isProcessing ? (
|
||||
<View style={[styles.btn, s.mt10]}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.mt20}
|
||||
onPress={onPressSendEmail}>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
gradients.blueLight.start,
|
||||
gradients.blueLight.end,
|
||||
]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[styles.btn]}>
|
||||
<Text type="button-lg" style={[s.white, s.bold]}>
|
||||
Send email
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, s.mt10]}
|
||||
onPress={onCancel}>
|
||||
<Text type="button-lg" style={pal.textLight}>
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text type="lg" style={styles.description}>
|
||||
Check your inbox for an email with the confirmation code to enter
|
||||
below:
|
||||
</Text>
|
||||
<BottomSheetTextInput
|
||||
style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]}
|
||||
placeholder="Confirmation code"
|
||||
placeholderTextColor={pal.textLight.color}
|
||||
value={confirmCode}
|
||||
onChangeText={setConfirmCode}
|
||||
/>
|
||||
<Text type="lg" style={styles.description}>
|
||||
Please enter your password as well:
|
||||
</Text>
|
||||
<BottomSheetTextInput
|
||||
style={[styles.textInput, pal.borderDark, pal.text]}
|
||||
placeholder="Password"
|
||||
placeholderTextColor={pal.textLight.color}
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
{error ? (
|
||||
<View style={styles.mt20}>
|
||||
<ErrorMessage message={error} />
|
||||
</View>
|
||||
) : undefined}
|
||||
{isProcessing ? (
|
||||
<View style={[styles.btn, s.mt10]}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, styles.evilBtn, styles.mt20]}
|
||||
onPress={onPressConfirmDelete}>
|
||||
<Text type="button-lg" style={[s.white, s.bold]}>
|
||||
Delete my account
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, s.mt10]}
|
||||
onPress={onCancel}>
|
||||
<Text type="button-lg" style={pal.textLight}>
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
innerContainer: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginTop: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
description: {
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 22,
|
||||
marginBottom: 10,
|
||||
},
|
||||
mt20: {
|
||||
marginTop: 20,
|
||||
},
|
||||
mb20: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
textInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 20,
|
||||
marginHorizontal: 20,
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 32,
|
||||
padding: 14,
|
||||
marginHorizontal: 20,
|
||||
},
|
||||
evilBtn: {
|
||||
backgroundColor: colors.red4,
|
||||
},
|
||||
})
|
|
@ -11,18 +11,17 @@ import {ScrollView, TextInput} from './util'
|
|||
import {PickedMedia} from '../util/images/image-crop-picker/ImageCropPicker'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {useStores} from '../../../state'
|
||||
import {ProfileViewModel} from '../../../state/models/profile-view'
|
||||
import {s, colors, gradients} from '../../lib/styles'
|
||||
import {
|
||||
enforceLen,
|
||||
MAX_DISPLAY_NAME,
|
||||
MAX_DESCRIPTION,
|
||||
} from '../../../lib/strings'
|
||||
import {isNetworkError} from '../../../lib/errors'
|
||||
import {compressIfNeeded} from '../../../lib/images'
|
||||
import {useStores} from 'state/index'
|
||||
import {ProfileViewModel} from 'state/models/profile-view'
|
||||
import {s, colors, gradients} from 'lib/styles'
|
||||
import {enforceLen} from 'lib/strings/helpers'
|
||||
import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants'
|
||||
import {compressIfNeeded} from 'lib/images'
|
||||
import {UserBanner} from '../util/UserBanner'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {cleanError, isNetworkError} from 'lib/strings/errors'
|
||||
|
||||
export const snapPoints = ['80%']
|
||||
|
||||
|
@ -35,6 +34,9 @@ export function Component({
|
|||
}) {
|
||||
const store = useStores()
|
||||
const [error, setError] = useState<string>('')
|
||||
const pal = usePalette('default')
|
||||
const {track} = useAnalytics()
|
||||
|
||||
const [isProcessing, setProcessing] = useState<boolean>(false)
|
||||
const [displayName, setDisplayName] = useState<string>(
|
||||
profileView.displayName || '',
|
||||
|
@ -54,24 +56,27 @@ export function Component({
|
|||
store.shell.closeModal()
|
||||
}
|
||||
const onSelectNewAvatar = async (img: PickedMedia) => {
|
||||
track('EditProfile:AvatarSelected')
|
||||
try {
|
||||
const finalImg = await compressIfNeeded(img, 300000)
|
||||
setNewUserAvatar(finalImg)
|
||||
const finalImg = await compressIfNeeded(img, 1000000)
|
||||
setNewUserAvatar({mediaType: 'photo', ...finalImg})
|
||||
setUserAvatar(finalImg.path)
|
||||
} catch (e: any) {
|
||||
setError(e.message || e.toString())
|
||||
setError(cleanError(e))
|
||||
}
|
||||
}
|
||||
const onSelectNewBanner = async (img: PickedMedia) => {
|
||||
track('EditProfile:BannerSelected')
|
||||
try {
|
||||
const finalImg = await compressIfNeeded(img, 500000)
|
||||
setNewUserBanner(finalImg)
|
||||
const finalImg = await compressIfNeeded(img, 1000000)
|
||||
setNewUserBanner({mediaType: 'photo', ...finalImg})
|
||||
setUserBanner(finalImg.path)
|
||||
} catch (e: any) {
|
||||
setError(e.message || e.toString())
|
||||
setError(cleanError(e))
|
||||
}
|
||||
}
|
||||
const onPressSave = async () => {
|
||||
track('EditProfile:Save')
|
||||
setProcessing(true)
|
||||
if (error) {
|
||||
setError('')
|
||||
|
@ -94,7 +99,7 @@ export function Component({
|
|||
'Failed to save your profile. Check your internet connection and try again.',
|
||||
)
|
||||
} else {
|
||||
setError(e.message)
|
||||
setError(cleanError(e))
|
||||
}
|
||||
}
|
||||
setProcessing(false)
|
||||
|
@ -103,13 +108,13 @@ export function Component({
|
|||
return (
|
||||
<View style={s.flex1}>
|
||||
<ScrollView style={styles.inner}>
|
||||
<Text style={styles.title}>Edit my profile</Text>
|
||||
<Text style={[styles.title, pal.text]}>Edit my profile</Text>
|
||||
<View style={styles.photos}>
|
||||
<UserBanner
|
||||
banner={userBanner}
|
||||
onSelectNewBanner={onSelectNewBanner}
|
||||
/>
|
||||
<View style={styles.avi}>
|
||||
<View style={[styles.avi, {borderColor: pal.colors.background}]}>
|
||||
<UserAvatar
|
||||
size={80}
|
||||
avatar={userAvatar}
|
||||
|
@ -127,7 +132,7 @@ export function Component({
|
|||
<View>
|
||||
<Text style={styles.label}>Display Name</Text>
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
style={[styles.textInput, pal.text]}
|
||||
placeholder="e.g. Alice Roberts"
|
||||
placeholderTextColor={colors.gray4}
|
||||
value={displayName}
|
||||
|
@ -135,9 +140,9 @@ export function Component({
|
|||
/>
|
||||
</View>
|
||||
<View style={s.pb10}>
|
||||
<Text style={styles.label}>Description</Text>
|
||||
<Text style={[styles.label, pal.text]}>Description</Text>
|
||||
<TextInput
|
||||
style={[styles.textArea]}
|
||||
style={[styles.textArea, pal.text]}
|
||||
placeholder="e.g. Artist, dog-lover, and memelord."
|
||||
placeholderTextColor={colors.gray4}
|
||||
multiline
|
||||
|
@ -162,7 +167,7 @@ export function Component({
|
|||
)}
|
||||
<TouchableOpacity style={s.mt5} onPress={onPressCancel}>
|
||||
<View style={[styles.btn]}>
|
||||
<Text style={[s.black, s.bold]}>Cancel</Text>
|
||||
<Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
|
|
|
@ -2,23 +2,26 @@ import React, {useRef, useEffect} from 'react'
|
|||
import {View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import BottomSheet from '@gorhom/bottom-sheet'
|
||||
import {useStores} from '../../../state'
|
||||
import {useStores} from 'state/index'
|
||||
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
|
||||
|
||||
import * as models from '../../../state/models/shell-ui'
|
||||
import * as models from 'state/models/shell-ui'
|
||||
|
||||
import * as ConfirmModal from './Confirm'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
import * as ServerInputModal from './ServerInput'
|
||||
import * as ReportPostModal from './ReportPost'
|
||||
import * as ReportAccountModal from './ReportAccount'
|
||||
import * as DeleteAccountModal from './DeleteAccount'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {StyleSheet} from 'react-native'
|
||||
|
||||
const CLOSED_SNAPPOINTS = ['10%']
|
||||
|
||||
export const Modal = observer(function Modal() {
|
||||
const store = useStores()
|
||||
const bottomSheetRef = useRef<BottomSheet>(null)
|
||||
|
||||
const pal = usePalette('default')
|
||||
const onBottomSheetChange = (snapPoint: number) => {
|
||||
if (snapPoint === -1) {
|
||||
store.shell.closeModal()
|
||||
|
@ -62,10 +65,21 @@ export const Modal = observer(function Modal() {
|
|||
)
|
||||
} else if (store.shell.activeModal?.name === 'report-post') {
|
||||
snapPoints = ReportPostModal.snapPoints
|
||||
element = <ReportPostModal.Component />
|
||||
element = (
|
||||
<ReportPostModal.Component
|
||||
{...(store.shell.activeModal as models.ReportPostModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'report-account') {
|
||||
snapPoints = ReportAccountModal.snapPoints
|
||||
element = <ReportAccountModal.Component />
|
||||
element = (
|
||||
<ReportAccountModal.Component
|
||||
{...(store.shell.activeModal as models.ReportAccountModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'delete-account') {
|
||||
snapPoints = DeleteAccountModal.snapPoints
|
||||
element = <DeleteAccountModal.Component />
|
||||
} else {
|
||||
element = <View />
|
||||
}
|
||||
|
@ -80,8 +94,17 @@ export const Modal = observer(function Modal() {
|
|||
backdropComponent={
|
||||
store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined
|
||||
}
|
||||
handleIndicatorStyle={{backgroundColor: pal.text.color}}
|
||||
handleStyle={[styles.handle, pal.view]}
|
||||
onChange={onBottomSheetChange}>
|
||||
{element}
|
||||
</BottomSheet>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
handle: {
|
||||
borderTopLeftRadius: 10,
|
||||
borderTopRightRadius: 10,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react'
|
||||
import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {useStores} from '../../../state'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
import * as models from '../../../state/models/shell-ui'
|
||||
import * as models from 'state/models/shell-ui'
|
||||
|
||||
import * as ConfirmModal from './Confirm'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
|
@ -48,9 +48,17 @@ export const Modal = observer(function Modal() {
|
|||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'report-post') {
|
||||
element = <ReportPostModal.Component />
|
||||
element = (
|
||||
<ReportPostModal.Component
|
||||
{...(store.shell.activeModal as models.ReportPostModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'report-account') {
|
||||
element = <ReportAccountModal.Component />
|
||||
element = (
|
||||
<ReportAccountModal.Component
|
||||
{...(store.shell.activeModal as models.ReportAccountModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'crop-image') {
|
||||
element = (
|
||||
<CropImageModal.Component
|
||||
|
|
|
@ -5,12 +5,15 @@ import {
|
|||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {ComAtprotoReportReasonType} from '@atproto/api'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {useStores} from '../../../state'
|
||||
import {s, colors, gradients} from '../../lib/styles'
|
||||
import {useStores} from 'state/index'
|
||||
import {s, colors, gradients} from 'lib/styles'
|
||||
import {RadioGroup, RadioGroupItem} from '../util/forms/RadioGroup'
|
||||
import {Text} from '../util/text/Text'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
||||
const ITEMS: RadioGroupItem[] = [
|
||||
{key: 'spam', label: 'Spam or excessive repeat posts'},
|
||||
|
@ -20,7 +23,7 @@ const ITEMS: RadioGroupItem[] = [
|
|||
|
||||
export const snapPoints = ['50%']
|
||||
|
||||
export function Component() {
|
||||
export function Component({did}: {did: string}) {
|
||||
const store = useStores()
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
|
@ -28,13 +31,30 @@ export function Component() {
|
|||
const onSelectIssue = (v: string) => setIssue(v)
|
||||
const onPress = async () => {
|
||||
setError('')
|
||||
if (!issue) {
|
||||
return
|
||||
}
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
// TODO
|
||||
// NOTE: we should update the lexicon of reasontype to include more options -prf
|
||||
let reasonType = ComAtprotoReportReasonType.OTHER
|
||||
if (issue === 'spam') {
|
||||
reasonType = ComAtprotoReportReasonType.SPAM
|
||||
}
|
||||
const reason = ITEMS.find(item => item.key === issue)?.label || ''
|
||||
await store.api.com.atproto.report.create({
|
||||
reasonType,
|
||||
reason,
|
||||
subject: {
|
||||
$type: 'com.atproto.repo.repoRef',
|
||||
did,
|
||||
},
|
||||
})
|
||||
Toast.show("Thank you for your report! We'll look into it promptly.")
|
||||
store.shell.closeModal()
|
||||
return
|
||||
} catch (e: any) {
|
||||
setError(e.toString())
|
||||
setError(cleanError(e))
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,12 +5,15 @@ import {
|
|||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {ComAtprotoReportReasonType} from '@atproto/api'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {useStores} from '../../../state'
|
||||
import {s, colors, gradients} from '../../lib/styles'
|
||||
import {useStores} from 'state/index'
|
||||
import {s, colors, gradients} from 'lib/styles'
|
||||
import {RadioGroup, RadioGroupItem} from '../util/forms/RadioGroup'
|
||||
import {Text} from '../util/text/Text'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
||||
const ITEMS: RadioGroupItem[] = [
|
||||
{key: 'spam', label: 'Spam or excessive repeat posts'},
|
||||
|
@ -21,7 +24,13 @@ const ITEMS: RadioGroupItem[] = [
|
|||
|
||||
export const snapPoints = ['50%']
|
||||
|
||||
export function Component() {
|
||||
export function Component({
|
||||
postUri,
|
||||
postCid,
|
||||
}: {
|
||||
postUri: string
|
||||
postCid: string
|
||||
}) {
|
||||
const store = useStores()
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
|
@ -29,13 +38,31 @@ export function Component() {
|
|||
const onSelectIssue = (v: string) => setIssue(v)
|
||||
const onPress = async () => {
|
||||
setError('')
|
||||
if (!issue) {
|
||||
return
|
||||
}
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
// TODO
|
||||
// NOTE: we should update the lexicon of reasontype to include more options -prf
|
||||
let reasonType = ComAtprotoReportReasonType.OTHER
|
||||
if (issue === 'spam') {
|
||||
reasonType = ComAtprotoReportReasonType.SPAM
|
||||
}
|
||||
const reason = ITEMS.find(item => item.key === issue)?.label || ''
|
||||
await store.api.com.atproto.report.create({
|
||||
reasonType,
|
||||
reason,
|
||||
subject: {
|
||||
$type: 'com.atproto.repo.recordRef',
|
||||
uri: postUri,
|
||||
cid: postCid,
|
||||
},
|
||||
})
|
||||
Toast.show("Thank you for your report! We'll look into it promptly.")
|
||||
store.shell.closeModal()
|
||||
return
|
||||
} catch (e: any) {
|
||||
setError(e.toString())
|
||||
setError(cleanError(e))
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,14 +6,10 @@ import {
|
|||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {ScrollView, TextInput} from './util'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {useStores} from '../../../state'
|
||||
import {s, colors} from '../../lib/styles'
|
||||
import {
|
||||
LOCAL_DEV_SERVICE,
|
||||
STAGING_SERVICE,
|
||||
PROD_SERVICE,
|
||||
} from '../../../state/index'
|
||||
import {LOGIN_INCLUDE_DEV_SERVERS} from '../../../build-flags'
|
||||
import {useStores} from 'state/index'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index'
|
||||
import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
|
||||
|
||||
export const snapPoints = ['80%']
|
||||
|
||||
|
@ -37,6 +33,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
|
|||
{LOGIN_INCLUDE_DEV_SERVERS ? (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
testID="localDevServerButton"
|
||||
style={styles.btn}
|
||||
onPress={() => doSelect(LOCAL_DEV_SERVICE)}>
|
||||
<Text style={styles.btnText}>Local dev server</Text>
|
||||
|
|
|
@ -5,10 +5,10 @@ import {Slider} from '@miblanchard/react-native-slider'
|
|||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {Text} from '../../util/text/Text'
|
||||
import {PickedMedia} from '../../util/images/image-crop-picker/types'
|
||||
import {s, gradients} from '../../../lib/styles'
|
||||
import {useStores} from '../../../../state'
|
||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||
import {SquareIcon, RectWideIcon, RectTallIcon} from '../../../lib/icons'
|
||||
import {s, gradients} from 'lib/styles'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons'
|
||||
|
||||
enum AspectRatio {
|
||||
Square = 'square',
|
||||
|
|
|
@ -1,31 +1,61 @@
|
|||
import React from 'react'
|
||||
import React, {MutableRefObject} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {CenteredView, FlatList} from '../util/Views'
|
||||
import {NotificationsViewModel} from '../../../state/models/notifications-view'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {NotificationsViewModel} from 'state/models/notifications-view'
|
||||
import {FeedItem} from './FeedItem'
|
||||
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {EmptyState} from '../util/EmptyState'
|
||||
import {OnScrollCb} from '../../lib/hooks/useOnMainScroll'
|
||||
import {s} from '../../lib/styles'
|
||||
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||
|
||||
export const Feed = observer(function Feed({
|
||||
view,
|
||||
scrollElRef,
|
||||
onPressTryAgain,
|
||||
onScroll,
|
||||
}: {
|
||||
view: NotificationsViewModel
|
||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||
onPressTryAgain?: () => void
|
||||
onScroll?: OnScrollCb
|
||||
}) {
|
||||
const data = React.useMemo(() => {
|
||||
let feedItems
|
||||
if (view.hasLoaded) {
|
||||
if (view.isEmpty) {
|
||||
feedItems = [EMPTY_FEED_ITEM]
|
||||
} else {
|
||||
feedItems = view.notifications
|
||||
}
|
||||
}
|
||||
return feedItems
|
||||
}, [view.hasLoaded, view.isEmpty, view.notifications])
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
try {
|
||||
await view.refresh()
|
||||
await view.markAllRead()
|
||||
} catch (err) {
|
||||
view.rootStore.log.error('Failed to refresh notifications feed', err)
|
||||
}
|
||||
}, [view])
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
try {
|
||||
await view.loadMore()
|
||||
} catch (err) {
|
||||
view.rootStore.log.error('Failed to load more notifications', err)
|
||||
}
|
||||
}, [view])
|
||||
|
||||
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
|
||||
// VirtualizedList: You have a large list that is slow to update - make sure your
|
||||
// renderItem function renders components that follow React performance best practices
|
||||
// like PureComponent, shouldComponentUpdate, etc
|
||||
const renderItem = ({item}: {item: any}) => {
|
||||
const renderItem = React.useCallback(({item}: {item: any}) => {
|
||||
if (item === EMPTY_FEED_ITEM) {
|
||||
return (
|
||||
<EmptyState
|
||||
|
@ -36,29 +66,20 @@ export const Feed = observer(function Feed({
|
|||
)
|
||||
}
|
||||
return <FeedItem item={item} />
|
||||
}
|
||||
const onRefresh = () => {
|
||||
view
|
||||
.refresh()
|
||||
.catch(err =>
|
||||
view.rootStore.log.error('Failed to refresh notifications feed', err),
|
||||
)
|
||||
}
|
||||
const onEndReached = () => {
|
||||
view
|
||||
.loadMore()
|
||||
.catch(err =>
|
||||
view.rootStore.log.error('Failed to load more notifications', err),
|
||||
)
|
||||
}
|
||||
let data
|
||||
if (view.hasLoaded) {
|
||||
if (view.isEmpty) {
|
||||
data = [EMPTY_FEED_ITEM]
|
||||
} else {
|
||||
data = view.notifications
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const FeedFooter = React.useCallback(
|
||||
() =>
|
||||
view.isLoading ? (
|
||||
<View style={styles.feedFooter}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<View />
|
||||
),
|
||||
[view],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={s.h100pct}>
|
||||
<CenteredView>
|
||||
|
@ -72,9 +93,11 @@ export const Feed = observer(function Feed({
|
|||
</CenteredView>
|
||||
{data && (
|
||||
<FlatList
|
||||
ref={scrollElRef}
|
||||
data={data}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
ListFooterComponent={FeedFooter}
|
||||
refreshing={view.isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
|
@ -87,5 +110,6 @@ export const Feed = observer(function Feed({
|
|||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
feedFooter: {paddingTop: 20},
|
||||
emptyState: {paddingVertical: 40},
|
||||
})
|
||||
|
|
|
@ -14,19 +14,19 @@ import {
|
|||
FontAwesomeIconStyle,
|
||||
Props,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {NotificationsViewItemModel} from '../../../state/models/notifications-view'
|
||||
import {PostThreadViewModel} from '../../../state/models/post-thread-view'
|
||||
import {s, colors} from '../../lib/styles'
|
||||
import {ago, pluralize} from '../../../lib/strings'
|
||||
import {HeartIconSolid} from '../../lib/icons'
|
||||
import {NotificationsViewItemModel} from 'state/models/notifications-view'
|
||||
import {PostThreadViewModel} from 'state/models/post-thread-view'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {ago} from 'lib/strings/time'
|
||||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {HeartIconSolid} from 'lib/icons'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {ImageHorzList} from '../util/images/ImageHorzList'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {Post} from '../post/Post'
|
||||
import {Link} from '../util/Link'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
|
||||
const MAX_AUTHORS = 5
|
||||
|
||||
|
@ -78,6 +78,10 @@ export const FeedItem = observer(function FeedItem({
|
|||
}
|
||||
|
||||
if (item.isReply || item.isMention) {
|
||||
if (item.additionalPost?.error) {
|
||||
// hide errors - it doesnt help the user to show them
|
||||
return <View />
|
||||
}
|
||||
return (
|
||||
<Link href={itemHref} title={itemTitle} noFeedback>
|
||||
<Post
|
||||
|
@ -347,12 +351,13 @@ function AdditionalPostText({
|
|||
additionalPost?: PostThreadViewModel
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
if (!additionalPost || !additionalPost.thread?.postRecord) {
|
||||
if (
|
||||
!additionalPost ||
|
||||
!additionalPost.thread?.postRecord ||
|
||||
additionalPost.error
|
||||
) {
|
||||
return <View />
|
||||
}
|
||||
if (additionalPost.error) {
|
||||
return <ErrorMessage message={additionalPost.error} />
|
||||
}
|
||||
const text = additionalPost.thread?.postRecord.text
|
||||
const images = (
|
||||
additionalPost.thread.post.embed as AppBskyEmbedImages.Presented
|
||||
|
|
|
@ -14,10 +14,10 @@ import {
|
|||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {useStores} from '../../../state'
|
||||
import {s} from '../../lib/styles'
|
||||
import {TABS_EXPLAINER} from '../../lib/assets'
|
||||
import {TABS_ENABLED} from '../../../build-flags'
|
||||
import {useStores} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
import {TABS_EXPLAINER} from 'lib/assets'
|
||||
import {TABS_ENABLED} from 'lib/build-flags'
|
||||
|
||||
const ROUTES = TABS_ENABLED
|
||||
? [
|
||||
|
@ -127,11 +127,15 @@ export const FeatureExplainer = () => {
|
|||
<View />
|
||||
)}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity onPress={onPressSkip}>
|
||||
<TouchableOpacity
|
||||
onPress={onPressSkip}
|
||||
testID="onboardFeatureExplainerSkipBtn">
|
||||
<Text style={[s.blue3, s.f18]}>Skip</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
<TouchableOpacity onPress={onPressNext}>
|
||||
<TouchableOpacity
|
||||
onPress={onPressNext}
|
||||
testID="onboardFeatureExplainerNextBtn">
|
||||
<Text style={[s.blue3, s.f18]}>Next</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
|
@ -2,7 +2,6 @@ import React, {useState} from 'react'
|
|||
import {
|
||||
Animated,
|
||||
Image,
|
||||
SafeAreaView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
|
@ -15,10 +14,10 @@ import {
|
|||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {CenteredView} from '../util/Views.web'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {useStores} from '../../../state'
|
||||
import {s, colors} from '../../lib/styles'
|
||||
import {TABS_EXPLAINER} from '../../lib/assets'
|
||||
import {TABS_ENABLED} from '../../../build-flags'
|
||||
import {useStores} from 'state/index'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {TABS_EXPLAINER} from 'lib/assets'
|
||||
import {TABS_ENABLED} from 'lib/build-flags'
|
||||
|
||||
const ROUTES = TABS_ENABLED
|
||||
? [
|
||||
|
|
|
@ -3,8 +3,8 @@ import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
|
|||
import {observer} from 'mobx-react-lite'
|
||||
import {SuggestedFollows} from '../discover/SuggestedFollows'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {useStores} from '../../../state'
|
||||
import {s} from '../../lib/styles'
|
||||
import {useStores} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export const Follows = observer(() => {
|
||||
const store = useStores()
|
||||
|
@ -18,13 +18,15 @@ export const Follows = observer(() => {
|
|||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<Text style={styles.title}>Suggested follows</Text>
|
||||
<SuggestedFollows onNoSuggestions={onNoSuggestions} />
|
||||
<View style={s.flex1}>
|
||||
<SuggestedFollows onNoSuggestions={onNoSuggestions} />
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity onPress={onPressNext}>
|
||||
<TouchableOpacity onPress={onPressNext} testID="onboardFollowsSkipBtn">
|
||||
<Text style={[s.blue3, s.f18]}>Skip</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
<TouchableOpacity onPress={onPressNext}>
|
||||
<TouchableOpacity onPress={onPressNext} testID="onboardFollowsNextBtn">
|
||||
<Text style={[s.blue3, s.f18]}>Next</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import React from 'react'
|
||||
import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {SafeAreaView, StyleSheet, TouchableOpacity} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {SuggestedFollows} from '../discover/SuggestedFollows'
|
||||
import {CenteredView} from '../util/Views.web'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {useStores} from '../../../state'
|
||||
import {s, colors} from '../../lib/styles'
|
||||
import {useStores} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export const Follows = observer(() => {
|
||||
const store = useStores()
|
||||
|
|
|
@ -5,13 +5,10 @@ import {CenteredView, FlatList} from '../util/Views'
|
|||
import {
|
||||
RepostedByViewModel,
|
||||
RepostedByItem,
|
||||
} from '../../../state/models/reposted-by-view'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
} from 'state/models/reposted-by-view'
|
||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {useStores} from '../../../state'
|
||||
import {s, colors} from '../../lib/styles'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
export const PostRepostedBy = observer(function PostRepostedBy({
|
||||
uri,
|
||||
|
@ -62,7 +59,15 @@ export const PostRepostedBy = observer(function PostRepostedBy({
|
|||
// loaded
|
||||
// =
|
||||
const renderItem = ({item}: {item: RepostedByItem}) => (
|
||||
<RepostedByItemCom item={item} />
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.did}
|
||||
did={item.did}
|
||||
declarationCid={item.declaration.cid}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
isFollowedBy={!!item.viewer?.followedBy}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<FlatList
|
||||
|
@ -83,57 +88,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({
|
|||
)
|
||||
})
|
||||
|
||||
const RepostedByItemCom = ({item}: {item: RepostedByItem}) => {
|
||||
return (
|
||||
<Link
|
||||
style={styles.outer}
|
||||
href={`/profile/${item.handle}`}
|
||||
title={item.handle}
|
||||
noFeedback>
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<UserAvatar
|
||||
size={40}
|
||||
displayName={item.displayName}
|
||||
handle={item.handle}
|
||||
avatar={item.avatar}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<Text style={[s.f15, s.bold]}>{item.displayName || item.handle}</Text>
|
||||
<Text style={[s.f14, s.gray5]}>@{item.handle}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
marginTop: 1,
|
||||
backgroundColor: colors.white,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
layoutAvi: {
|
||||
width: 60,
|
||||
paddingLeft: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
avi: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
layoutContent: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
footer: {
|
||||
height: 200,
|
||||
paddingTop: 20,
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import React, {useRef} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {ActivityIndicator, View} from 'react-native'
|
||||
import {ActivityIndicator} from 'react-native'
|
||||
import {CenteredView, FlatList} from '../util/Views'
|
||||
import {
|
||||
PostThreadViewModel,
|
||||
PostThreadViewPostModel,
|
||||
} from '../../../state/models/post-thread-view'
|
||||
} from 'state/models/post-thread-view'
|
||||
import {PostThreadItem} from './PostThreadItem'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {s} from '../../lib/styles'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export const PostThread = observer(function PostThread({
|
||||
uri,
|
||||
|
@ -18,15 +18,24 @@ export const PostThread = observer(function PostThread({
|
|||
view: PostThreadViewModel
|
||||
}) {
|
||||
const ref = useRef<FlatList>(null)
|
||||
const posts = view.thread ? Array.from(flattenThread(view.thread)) : []
|
||||
const onRefresh = () => {
|
||||
view
|
||||
?.refresh()
|
||||
.catch(err =>
|
||||
view.rootStore.log.error('Failed to refresh posts thread', err),
|
||||
)
|
||||
}
|
||||
const onLayout = () => {
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||
const posts = React.useMemo(
|
||||
() => (view.thread ? Array.from(flattenThread(view.thread)) : []),
|
||||
[view.thread],
|
||||
)
|
||||
|
||||
// events
|
||||
// =
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
view?.refresh()
|
||||
} catch (err) {
|
||||
view.rootStore.log.error('Failed to refresh posts thread', err)
|
||||
}
|
||||
setIsRefreshing(false)
|
||||
}, [view, setIsRefreshing])
|
||||
const onLayout = React.useCallback(() => {
|
||||
const index = posts.findIndex(post => post._isHighlightedPost)
|
||||
if (index !== -1) {
|
||||
ref.current?.scrollToIndex({
|
||||
|
@ -35,17 +44,20 @@ export const PostThread = observer(function PostThread({
|
|||
viewOffset: 40,
|
||||
})
|
||||
}
|
||||
}
|
||||
const onScrollToIndexFailed = (info: {
|
||||
index: number
|
||||
highestMeasuredFrameIndex: number
|
||||
averageItemLength: number
|
||||
}) => {
|
||||
ref.current?.scrollToOffset({
|
||||
animated: false,
|
||||
offset: info.averageItemLength * info.index,
|
||||
})
|
||||
}
|
||||
}, [posts, ref])
|
||||
const onScrollToIndexFailed = React.useCallback(
|
||||
(info: {
|
||||
index: number
|
||||
highestMeasuredFrameIndex: number
|
||||
averageItemLength: number
|
||||
}) => {
|
||||
ref.current?.scrollToOffset({
|
||||
animated: false,
|
||||
offset: info.averageItemLength * info.index,
|
||||
})
|
||||
},
|
||||
[ref],
|
||||
)
|
||||
|
||||
// loading
|
||||
// =
|
||||
|
@ -76,9 +88,10 @@ export const PostThread = observer(function PostThread({
|
|||
<FlatList
|
||||
ref={ref}
|
||||
data={posts}
|
||||
initialNumToRender={posts.length}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
refreshing={view.isRefreshing}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
onLayout={onLayout}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, {useMemo, useState} from 'react'
|
||||
import React from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
|
@ -7,22 +7,23 @@ import {
|
|||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {PostThreadViewPostModel} from '../../../state/models/post-thread-view'
|
||||
import {PostThreadViewPostModel} from 'state/models/post-thread-view'
|
||||
import {Link} from '../util/Link'
|
||||
import {RichText} from '../util/text/RichText'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {PostDropdownBtn} from '../util/forms/DropdownButton'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {s} from '../../lib/styles'
|
||||
import {ago, pluralize} from '../../../lib/strings'
|
||||
import {useStores} from '../../../state'
|
||||
import {s} from 'lib/styles'
|
||||
import {ago} from 'lib/strings/time'
|
||||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {useStores} from 'state/index'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {PostEmbeds} from '../util/PostEmbeds'
|
||||
import {PostCtrls} from '../util/PostCtrls'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {ComposePrompt} from '../composer/Prompt'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
const PARENT_REPLY_LINE_LENGTH = 8
|
||||
|
||||
|
@ -35,29 +36,31 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
}) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const [deleted, setDeleted] = useState(false)
|
||||
const [deleted, setDeleted] = React.useState(false)
|
||||
const record = item.postRecord
|
||||
const hasEngagement = item.post.upvoteCount || item.post.repostCount
|
||||
|
||||
const itemHref = useMemo(() => {
|
||||
const itemUri = item.post.uri
|
||||
const itemCid = item.post.cid
|
||||
const itemHref = React.useMemo(() => {
|
||||
const urip = new AtUri(item.post.uri)
|
||||
return `/profile/${item.post.author.handle}/post/${urip.rkey}`
|
||||
}, [item.post.uri, item.post.author.handle])
|
||||
const itemTitle = `Post by ${item.post.author.handle}`
|
||||
const authorHref = `/profile/${item.post.author.handle}`
|
||||
const authorTitle = item.post.author.handle
|
||||
const upvotesHref = useMemo(() => {
|
||||
const upvotesHref = React.useMemo(() => {
|
||||
const urip = new AtUri(item.post.uri)
|
||||
return `/profile/${item.post.author.handle}/post/${urip.rkey}/upvoted-by`
|
||||
}, [item.post.uri, item.post.author.handle])
|
||||
const upvotesTitle = 'Likes on this post'
|
||||
const repostsHref = useMemo(() => {
|
||||
const repostsHref = React.useMemo(() => {
|
||||
const urip = new AtUri(item.post.uri)
|
||||
return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by`
|
||||
}, [item.post.uri, item.post.author.handle])
|
||||
const repostsTitle = 'Reposts of this post'
|
||||
|
||||
const onPressReply = () => {
|
||||
const onPressReply = React.useCallback(() => {
|
||||
store.shell.openComposer({
|
||||
replyTo: {
|
||||
uri: item.post.uri,
|
||||
|
@ -71,22 +74,22 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
},
|
||||
onPost: onPostReply,
|
||||
})
|
||||
}
|
||||
const onPressToggleRepost = () => {
|
||||
item
|
||||
}, [store, item, record, onPostReply])
|
||||
const onPressToggleRepost = React.useCallback(() => {
|
||||
return item
|
||||
.toggleRepost()
|
||||
.catch(e => store.log.error('Failed to toggle repost', e))
|
||||
}
|
||||
const onPressToggleUpvote = () => {
|
||||
item
|
||||
}, [item, store])
|
||||
const onPressToggleUpvote = React.useCallback(() => {
|
||||
return item
|
||||
.toggleUpvote()
|
||||
.catch(e => store.log.error('Failed to toggle upvote', e))
|
||||
}
|
||||
const onCopyPostText = () => {
|
||||
}, [item, store])
|
||||
const onCopyPostText = React.useCallback(() => {
|
||||
Clipboard.setString(record?.text || '')
|
||||
Toast.show('Copied to clipboard')
|
||||
}
|
||||
const onDeletePost = () => {
|
||||
}, [record])
|
||||
const onDeletePost = React.useCallback(() => {
|
||||
item.delete().then(
|
||||
() => {
|
||||
setDeleted(true)
|
||||
|
@ -97,7 +100,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
Toast.show('Failed to delete post, please try again')
|
||||
},
|
||||
)
|
||||
}
|
||||
}, [item, store])
|
||||
|
||||
if (!record) {
|
||||
return <ErrorMessage message="Invalid or unsupported post record" />
|
||||
|
@ -154,6 +157,8 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
<View style={s.flex1} />
|
||||
<PostDropdownBtn
|
||||
style={styles.metaItem}
|
||||
itemUri={itemUri}
|
||||
itemCid={itemCid}
|
||||
itemHref={itemHref}
|
||||
itemTitle={itemTitle}
|
||||
isAuthor={item.post.author.did === store.me.did}
|
||||
|
@ -179,7 +184,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
</View>
|
||||
</View>
|
||||
<View style={[s.pl10, s.pr10, s.pb10]}>
|
||||
{record.text ? (
|
||||
{item.richText?.text ? (
|
||||
<View
|
||||
style={[
|
||||
styles.postTextContainer,
|
||||
|
@ -187,8 +192,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
]}>
|
||||
<RichText
|
||||
type="post-text-lg"
|
||||
text={record.text}
|
||||
entities={record.entities}
|
||||
richText={item.richText}
|
||||
lineHeight={1.3}
|
||||
/>
|
||||
</View>
|
||||
|
@ -233,6 +237,8 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
<View style={[s.pl10, s.pb5]}>
|
||||
<PostCtrls
|
||||
big
|
||||
itemUri={itemUri}
|
||||
itemCid={itemCid}
|
||||
itemHref={itemHref}
|
||||
itemTitle={itemTitle}
|
||||
isAuthor={item.post.author.did === store.me.did}
|
||||
|
@ -301,12 +307,11 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
<FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} />
|
||||
<Text type="sm">This post is by a muted account.</Text>
|
||||
</View>
|
||||
) : record.text ? (
|
||||
) : item.richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
type="post-text"
|
||||
text={record.text}
|
||||
entities={record.entities}
|
||||
richText={item.richText}
|
||||
style={pal.text}
|
||||
lineHeight={1.3}
|
||||
/>
|
||||
|
@ -314,6 +319,8 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
) : undefined}
|
||||
<PostEmbeds embed={item.post.embed} style={s.mb10} />
|
||||
<PostCtrls
|
||||
itemUri={itemUri}
|
||||
itemCid={itemCid}
|
||||
itemHref={itemHref}
|
||||
itemTitle={itemTitle}
|
||||
isAuthor={item.post.author.did === store.me.did}
|
||||
|
@ -341,7 +348,12 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
href={itemHref}
|
||||
title={itemTitle}
|
||||
noFeedback>
|
||||
<Text style={pal.link}>Load more</Text>
|
||||
<Text style={pal.link}>Continue thread...</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="angle-right"
|
||||
style={pal.link as FontAwesomeIconStyle}
|
||||
size={18}
|
||||
/>
|
||||
</Link>
|
||||
) : undefined}
|
||||
</>
|
||||
|
@ -433,8 +445,12 @@ const styles = StyleSheet.create({
|
|||
marginRight: 10,
|
||||
},
|
||||
loadMore: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
borderTopWidth: 1,
|
||||
paddingLeft: 28,
|
||||
paddingLeft: 80,
|
||||
paddingRight: 20,
|
||||
paddingVertical: 10,
|
||||
marginBottom: 8,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -2,14 +2,10 @@ import React, {useEffect} from 'react'
|
|||
import {observer} from 'mobx-react-lite'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {CenteredView, FlatList} from '../util/Views'
|
||||
import {VotesViewModel, VoteItem} from '../../../state/models/votes-view'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {VotesViewModel, VoteItem} from 'state/models/votes-view'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {useStores} from '../../../state'
|
||||
import {s} from '../../lib/styles'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
export const PostVotedBy = observer(function PostVotedBy({
|
||||
uri,
|
||||
|
@ -57,7 +53,17 @@ export const PostVotedBy = observer(function PostVotedBy({
|
|||
|
||||
// loaded
|
||||
// =
|
||||
const renderItem = ({item}: {item: VoteItem}) => <LikedByItem item={item} />
|
||||
const renderItem = ({item}: {item: VoteItem}) => (
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.actor.did}
|
||||
did={item.actor.did}
|
||||
declarationCid={item.actor.declaration.cid}
|
||||
handle={item.actor.handle}
|
||||
displayName={item.actor.displayName}
|
||||
avatar={item.actor.avatar}
|
||||
isFollowedBy={!!item.actor.viewer?.followedBy}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<FlatList
|
||||
data={view.votes}
|
||||
|
@ -77,62 +83,7 @@ export const PostVotedBy = observer(function PostVotedBy({
|
|||
)
|
||||
})
|
||||
|
||||
const LikedByItem = ({item}: {item: VoteItem}) => {
|
||||
const pal = usePalette('default')
|
||||
|
||||
return (
|
||||
<Link
|
||||
style={[styles.outer, pal.view]}
|
||||
href={`/profile/${item.actor.handle}`}
|
||||
title={item.actor.handle}
|
||||
noFeedback>
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<UserAvatar
|
||||
size={40}
|
||||
displayName={item.actor.displayName}
|
||||
handle={item.actor.handle}
|
||||
avatar={item.actor.avatar}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<Text style={[s.f15, s.bold, pal.text]}>
|
||||
{item.actor.displayName || item.actor.handle}
|
||||
</Text>
|
||||
<Text style={[s.f14, s.gray5, pal.textLight]}>
|
||||
@{item.actor.handle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
marginTop: 1,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
layoutAvi: {
|
||||
width: 60,
|
||||
paddingLeft: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
avi: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
layoutContent: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
footer: {
|
||||
height: 200,
|
||||
paddingTop: 20,
|
||||
|
|
|
@ -10,7 +10,7 @@ import {observer} from 'mobx-react-lite'
|
|||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import {AtUri} from '../../../third-party/uri'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {PostThreadViewModel} from '../../../state/models/post-thread-view'
|
||||
import {PostThreadViewModel} from 'state/models/post-thread-view'
|
||||
import {Link} from '../util/Link'
|
||||
import {UserInfoText} from '../util/UserInfoText'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
|
@ -20,9 +20,9 @@ import {Text} from '../util/text/Text'
|
|||
import {RichText} from '../util/text/RichText'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {useStores} from '../../../state'
|
||||
import {s, colors} from '../../lib/styles'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {useStores} from 'state/index'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export const Post = observer(function Post({
|
||||
uri,
|
||||
|
@ -80,6 +80,8 @@ export const Post = observer(function Post({
|
|||
const item = view.thread
|
||||
const record = view.thread.postRecord
|
||||
|
||||
const itemUri = item.post.uri
|
||||
const itemCid = item.post.cid
|
||||
const itemUrip = new AtUri(item.post.uri)
|
||||
const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}`
|
||||
const itemTitle = `Post by ${item.post.author.handle}`
|
||||
|
@ -105,12 +107,12 @@ export const Post = observer(function Post({
|
|||
})
|
||||
}
|
||||
const onPressToggleRepost = () => {
|
||||
item
|
||||
return item
|
||||
.toggleRepost()
|
||||
.catch(e => store.log.error('Failed to toggle repost', e))
|
||||
}
|
||||
const onPressToggleUpvote = () => {
|
||||
item
|
||||
return item
|
||||
.toggleUpvote()
|
||||
.catch(e => store.log.error('Failed to toggle upvote', e))
|
||||
}
|
||||
|
@ -178,18 +180,19 @@ export const Post = observer(function Post({
|
|||
<FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} />
|
||||
<Text type="sm">This post is by a muted account.</Text>
|
||||
</View>
|
||||
) : record.text ? (
|
||||
) : item.richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
type="post-text"
|
||||
text={record.text}
|
||||
entities={record.entities}
|
||||
richText={item.richText}
|
||||
lineHeight={1.3}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
<PostEmbeds embed={item.post.embed} style={s.mb10} />
|
||||
<PostCtrls
|
||||
itemUri={itemUri}
|
||||
itemCid={itemCid}
|
||||
itemHref={itemHref}
|
||||
itemTitle={itemTitle}
|
||||
isAuthor={item.post.author.did === store.me.did}
|
||||
|
|
|
@ -4,8 +4,8 @@ import {StyleProp, StyleSheet, TextStyle, View} from 'react-native'
|
|||
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {PostModel} from '../../../state/models/post'
|
||||
import {useStores} from '../../../state'
|
||||
import {PostModel} from 'state/models/post'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
export const PostText = observer(function PostText({
|
||||
uri,
|
||||
|
|
|
@ -1,47 +1,5 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
|
||||
export function ComposerPrompt({
|
||||
onPressCompose,
|
||||
}: {
|
||||
export function ComposerPrompt(_opts: {
|
||||
onPressCompose: (imagesOpen?: boolean) => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View style={[pal.view, pal.border, styles.container]}>
|
||||
<TouchableOpacity
|
||||
testID="composePromptButton"
|
||||
onPress={() => onPressCompose(false)}
|
||||
style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}>
|
||||
<Text type="button" style={pal.text}>
|
||||
New post
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => onPressCompose(true)}
|
||||
style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}>
|
||||
<Text type="button" style={pal.text}>
|
||||
Share photo
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
btn: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 30,
|
||||
marginRight: 10,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {s} from '../../lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export function ComposerPrompt({
|
||||
onPressCompose,
|
||||
|
|
|
@ -11,103 +11,144 @@ import {CenteredView, FlatList} from '../util/Views'
|
|||
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {EmptyState} from '../util/EmptyState'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {FeedModel} from '../../../state/models/feed-view'
|
||||
import {FeedModel} from 'state/models/feed-view'
|
||||
import {FeedItem} from './FeedItem'
|
||||
import {ComposerPrompt} from './ComposerPrompt'
|
||||
import {OnScrollCb} from '../../lib/hooks/useOnMainScroll'
|
||||
import {s} from '../../lib/styles'
|
||||
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
|
||||
import {s} from 'lib/styles'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
|
||||
const COMPOSE_PROMPT_ITEM = {_reactKey: '__prompt__'}
|
||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||
const ERROR_FEED_ITEM = {_reactKey: '__error__'}
|
||||
|
||||
export const Feed = observer(function Feed({
|
||||
feed,
|
||||
style,
|
||||
scrollElRef,
|
||||
onPressCompose,
|
||||
onPressTryAgain,
|
||||
onPressCompose,
|
||||
onScroll,
|
||||
testID,
|
||||
headerOffset = 0,
|
||||
}: {
|
||||
feed: FeedModel
|
||||
style?: StyleProp<ViewStyle>
|
||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||
onPressCompose: (imagesOpen?: boolean) => void
|
||||
onPressTryAgain?: () => void
|
||||
onPressCompose: (imagesOpen?: boolean) => void
|
||||
onScroll?: OnScrollCb
|
||||
testID?: string
|
||||
headerOffset?: number
|
||||
}) {
|
||||
const {track} = useAnalytics()
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||
|
||||
const data = React.useMemo(() => {
|
||||
let feedItems: any[] = []
|
||||
if (feed.hasLoaded) {
|
||||
feedItems = feedItems.concat([COMPOSE_PROMPT_ITEM])
|
||||
if (feed.hasError) {
|
||||
feedItems = feedItems.concat([ERROR_FEED_ITEM])
|
||||
}
|
||||
if (feed.isEmpty) {
|
||||
feedItems = feedItems.concat([EMPTY_FEED_ITEM])
|
||||
} else {
|
||||
feedItems = feedItems.concat(feed.feed)
|
||||
}
|
||||
}
|
||||
return feedItems
|
||||
}, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.feed])
|
||||
|
||||
// events
|
||||
// =
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
track('Feed:onRefresh')
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
await feed.refresh()
|
||||
} catch (err) {
|
||||
feed.rootStore.log.error('Failed to refresh posts feed', err)
|
||||
}
|
||||
setIsRefreshing(false)
|
||||
}, [feed, track, setIsRefreshing])
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
track('Feed:onEndReached')
|
||||
try {
|
||||
await feed.loadMore()
|
||||
} catch (err) {
|
||||
feed.rootStore.log.error('Failed to load more posts', err)
|
||||
}
|
||||
}, [feed, track])
|
||||
|
||||
// rendering
|
||||
// =
|
||||
|
||||
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
|
||||
// VirtualizedList: You have a large list that is slow to update - make sure your
|
||||
// renderItem function renders components that follow React performance best practices
|
||||
// like PureComponent, shouldComponentUpdate, etc
|
||||
const renderItem = ({item}: {item: any}) => {
|
||||
if (item === COMPOSE_PROMPT_ITEM) {
|
||||
return <ComposerPrompt onPressCompose={onPressCompose} />
|
||||
} else if (item === EMPTY_FEED_ITEM) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="bars"
|
||||
message="This feed is empty!"
|
||||
style={styles.emptyState}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return <FeedItem item={item} />
|
||||
}
|
||||
}
|
||||
const onRefresh = () => {
|
||||
feed
|
||||
.refresh()
|
||||
.catch(err =>
|
||||
feed.rootStore.log.error('Failed to refresh posts feed', err),
|
||||
)
|
||||
}
|
||||
const onEndReached = () => {
|
||||
feed
|
||||
.loadMore()
|
||||
.catch(err => feed.rootStore.log.error('Failed to load more posts', err))
|
||||
}
|
||||
let data
|
||||
if (feed.hasLoaded) {
|
||||
if (feed.isEmpty) {
|
||||
data = [COMPOSE_PROMPT_ITEM, EMPTY_FEED_ITEM]
|
||||
} else {
|
||||
data = [COMPOSE_PROMPT_ITEM].concat(feed.feed)
|
||||
}
|
||||
}
|
||||
const FeedFooter = () =>
|
||||
feed.isLoading ? (
|
||||
<View style={styles.feedFooter}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<View />
|
||||
)
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
<CenteredView>
|
||||
{!data && <ComposerPrompt onPressCompose={onPressCompose} />}
|
||||
{feed.isLoading && !data && <PostFeedLoadingPlaceholder />}
|
||||
{feed.hasError && (
|
||||
const renderItem = React.useCallback(
|
||||
({item}: {item: any}) => {
|
||||
if (item === COMPOSE_PROMPT_ITEM) {
|
||||
return <ComposerPrompt onPressCompose={onPressCompose} />
|
||||
} else if (item === EMPTY_FEED_ITEM) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="bars"
|
||||
message="This feed is empty!"
|
||||
style={styles.emptyState}
|
||||
/>
|
||||
)
|
||||
} else if (item === ERROR_FEED_ITEM) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={feed.error}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
)}
|
||||
</CenteredView>
|
||||
{feed.hasLoaded && data && (
|
||||
)
|
||||
}
|
||||
return <FeedItem item={item} />
|
||||
},
|
||||
[feed, onPressTryAgain, onPressCompose],
|
||||
)
|
||||
|
||||
const FeedFooter = React.useCallback(
|
||||
() =>
|
||||
feed.isLoading ? (
|
||||
<View style={styles.feedFooter}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<View />
|
||||
),
|
||||
[feed],
|
||||
)
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
{feed.isLoading && data.length === 0 && (
|
||||
<CenteredView style={{paddingTop: headerOffset}}>
|
||||
<PostFeedLoadingPlaceholder />
|
||||
</CenteredView>
|
||||
)}
|
||||
{data.length > 0 && (
|
||||
<FlatList
|
||||
ref={scrollElRef}
|
||||
data={data}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
ListFooterComponent={FeedFooter}
|
||||
refreshing={feed.isRefreshing}
|
||||
refreshing={isRefreshing}
|
||||
contentContainerStyle={s.contentContainer}
|
||||
onScroll={onScroll}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
removeClippedSubviews={true}
|
||||
contentInset={{top: headerOffset}}
|
||||
contentOffset={{x: 0, y: headerOffset * -1}}
|
||||
progressViewOffset={headerOffset}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {FeedItemModel} from '../../../state/models/feed-view'
|
||||
import {FeedItemModel} from 'state/models/feed-view'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserInfoText} from '../util/UserInfoText'
|
||||
|
@ -18,9 +18,10 @@ import {PostEmbeds} from '../util/PostEmbeds'
|
|||
import {RichText} from '../util/text/RichText'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {s} from '../../lib/styles'
|
||||
import {useStores} from '../../../state'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
|
||||
export const FeedItem = observer(function ({
|
||||
item,
|
||||
|
@ -33,8 +34,11 @@ export const FeedItem = observer(function ({
|
|||
}) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const {track} = useAnalytics()
|
||||
const [deleted, setDeleted] = useState(false)
|
||||
const record = item.postRecord
|
||||
const itemUri = item.post.uri
|
||||
const itemCid = item.post.cid
|
||||
const itemHref = useMemo(() => {
|
||||
const urip = new AtUri(item.post.uri)
|
||||
return `/profile/${item.post.author.handle}/post/${urip.rkey}`
|
||||
|
@ -50,6 +54,7 @@ export const FeedItem = observer(function ({
|
|||
}, [record?.reply])
|
||||
|
||||
const onPressReply = () => {
|
||||
track('FeedItem:PostReply')
|
||||
store.shell.openComposer({
|
||||
replyTo: {
|
||||
uri: item.post.uri,
|
||||
|
@ -64,12 +69,14 @@ export const FeedItem = observer(function ({
|
|||
})
|
||||
}
|
||||
const onPressToggleRepost = () => {
|
||||
item
|
||||
track('FeedItem:PostRepost')
|
||||
return item
|
||||
.toggleRepost()
|
||||
.catch(e => store.log.error('Failed to toggle repost', e))
|
||||
}
|
||||
const onPressToggleUpvote = () => {
|
||||
item
|
||||
track('FeedItem:PostLike')
|
||||
return item
|
||||
.toggleUpvote()
|
||||
.catch(e => store.log.error('Failed to toggle upvote', e))
|
||||
}
|
||||
|
@ -78,6 +85,7 @@ export const FeedItem = observer(function ({
|
|||
Toast.show('Copied to clipboard')
|
||||
}
|
||||
const onDeletePost = () => {
|
||||
track('FeedItem:PostDelete')
|
||||
item.delete().then(
|
||||
() => {
|
||||
setDeleted(true)
|
||||
|
@ -195,12 +203,11 @@ export const FeedItem = observer(function ({
|
|||
<FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} />
|
||||
<Text type="sm">This post is by a muted account.</Text>
|
||||
</View>
|
||||
) : record.text ? (
|
||||
) : item.richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
type="post-text"
|
||||
text={record.text}
|
||||
entities={record.entities}
|
||||
richText={item.richText}
|
||||
lineHeight={1.3}
|
||||
/>
|
||||
</View>
|
||||
|
@ -210,6 +217,8 @@ export const FeedItem = observer(function ({
|
|||
) : null}
|
||||
<PostCtrls
|
||||
style={styles.ctrls}
|
||||
itemUri={itemUri}
|
||||
itemCid={itemCid}
|
||||
itemHref={itemHref}
|
||||
itemTitle={itemTitle}
|
||||
isAuthor={item.post.author.did === store.me.did}
|
||||
|
|
|
@ -1,23 +1,29 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {s} from '../../lib/styles'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from 'state/index'
|
||||
import * as apilib from 'lib/api/index'
|
||||
|
||||
export function ProfileCard({
|
||||
handle,
|
||||
displayName,
|
||||
avatar,
|
||||
description,
|
||||
isFollowedBy,
|
||||
renderButton,
|
||||
onPressButton,
|
||||
}: {
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
description?: string
|
||||
isFollowedBy?: boolean
|
||||
renderButton?: () => JSX.Element
|
||||
onPressButton?: () => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
|
@ -36,30 +42,117 @@ export function ProfileCard({
|
|||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<Text style={[s.bold, pal.text]} numberOfLines={1}>
|
||||
<Text type="lg" style={[s.bold, pal.text]} numberOfLines={1}>
|
||||
{displayName || handle}
|
||||
</Text>
|
||||
<Text type="sm" style={[pal.textLight]} numberOfLines={1}>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
@{handle}
|
||||
</Text>
|
||||
{isFollowedBy && (
|
||||
<View style={s.flexRow}>
|
||||
<View style={[s.mt5, pal.btn, styles.pill]}>
|
||||
<Text type="xs">Follows You</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{renderButton ? (
|
||||
<View style={styles.layoutButton}>
|
||||
<TouchableOpacity
|
||||
onPress={onPressButton}
|
||||
style={[styles.btn, pal.btn]}>
|
||||
{renderButton()}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.layoutButton}>{renderButton()}</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
{description ? (
|
||||
<View style={styles.details}>
|
||||
<Text style={pal.text} numberOfLines={4}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProfileCardWithFollowBtn = observer(
|
||||
({
|
||||
did,
|
||||
declarationCid,
|
||||
handle,
|
||||
displayName,
|
||||
avatar,
|
||||
description,
|
||||
isFollowedBy,
|
||||
}: {
|
||||
did: string
|
||||
declarationCid: string
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
description?: string
|
||||
isFollowedBy?: boolean
|
||||
}) => {
|
||||
const store = useStores()
|
||||
const isMe = store.me.handle === handle
|
||||
const isFollowing = store.me.follows.isFollowing(did)
|
||||
const onToggleFollow = async () => {
|
||||
if (store.me.follows.isFollowing(did)) {
|
||||
try {
|
||||
await apilib.unfollow(store, store.me.follows.getFollowUri(did))
|
||||
store.me.follows.removeFollow(did)
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed fo delete follow', e)
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await apilib.follow(store, did, declarationCid)
|
||||
store.me.follows.addFollow(did, res.uri)
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed fo create follow', e)
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<ProfileCard
|
||||
handle={handle}
|
||||
displayName={displayName}
|
||||
avatar={avatar}
|
||||
description={description}
|
||||
isFollowedBy={isFollowedBy}
|
||||
renderButton={
|
||||
isMe
|
||||
? undefined
|
||||
: () => (
|
||||
<FollowBtn isFollowing={isFollowing} onPress={onToggleFollow} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
function FollowBtn({
|
||||
isFollowing,
|
||||
onPress,
|
||||
}: {
|
||||
isFollowing: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<View style={[styles.btn, pal.btn]}>
|
||||
<Text type="button" style={[pal.text]}>
|
||||
{isFollowing ? 'Unfollow' : 'Follow'}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
|
@ -68,7 +161,7 @@ const styles = StyleSheet.create({
|
|||
layoutAvi: {
|
||||
width: 60,
|
||||
paddingLeft: 10,
|
||||
paddingTop: 10,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
avi: {
|
||||
|
@ -80,19 +173,26 @@ const styles = StyleSheet.create({
|
|||
layoutContent: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
paddingTop: 12,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
layoutButton: {
|
||||
paddingRight: 10,
|
||||
},
|
||||
details: {
|
||||
paddingLeft: 60,
|
||||
paddingRight: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
pill: {
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 7,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 50,
|
||||
marginLeft: 6,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -4,15 +4,11 @@ import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
|||
import {
|
||||
UserFollowersViewModel,
|
||||
FollowerItem,
|
||||
} from '../../../state/models/user-followers-view'
|
||||
} from 'state/models/user-followers-view'
|
||||
import {CenteredView, FlatList} from '../util/Views'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {useStores} from '../../../state'
|
||||
import {s} from '../../lib/styles'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {ProfileCardWithFollowBtn} from './ProfileCard'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
export const ProfileFollowers = observer(function ProfileFollowers({
|
||||
name,
|
||||
|
@ -63,7 +59,15 @@ export const ProfileFollowers = observer(function ProfileFollowers({
|
|||
// loaded
|
||||
// =
|
||||
const renderItem = ({item}: {item: FollowerItem}) => (
|
||||
<User key={item.did} item={item} />
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.did}
|
||||
did={item.did}
|
||||
declarationCid={item.declaration.cid}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
isFollowedBy={!!item.viewer?.followedBy}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<FlatList
|
||||
|
@ -84,55 +88,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
|
|||
)
|
||||
})
|
||||
|
||||
const User = ({item}: {item: FollowerItem}) => {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<Link
|
||||
style={[styles.outer, pal.view, pal.border]}
|
||||
href={`/profile/${item.handle}`}
|
||||
title={item.handle}
|
||||
noFeedback>
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<UserAvatar
|
||||
size={40}
|
||||
displayName={item.displayName}
|
||||
handle={item.handle}
|
||||
avatar={item.avatar}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<Text style={[s.bold, pal.text]}>
|
||||
{item.displayName || item.handle}
|
||||
</Text>
|
||||
<Text type="sm" style={[pal.textLight]}>
|
||||
@{item.handle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
layoutAvi: {
|
||||
width: 60,
|
||||
paddingLeft: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
layoutContent: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
footer: {
|
||||
height: 200,
|
||||
paddingTop: 20,
|
||||
|
|
|
@ -2,17 +2,10 @@ import React, {useEffect} from 'react'
|
|||
import {observer} from 'mobx-react-lite'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {CenteredView, FlatList} from '../util/Views'
|
||||
import {
|
||||
UserFollowsViewModel,
|
||||
FollowItem,
|
||||
} from '../../../state/models/user-follows-view'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserFollowsViewModel, FollowItem} from 'state/models/user-follows-view'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {useStores} from '../../../state'
|
||||
import {s} from '../../lib/styles'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {ProfileCardWithFollowBtn} from './ProfileCard'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
export const ProfileFollows = observer(function ProfileFollows({
|
||||
name,
|
||||
|
@ -63,7 +56,15 @@ export const ProfileFollows = observer(function ProfileFollows({
|
|||
// loaded
|
||||
// =
|
||||
const renderItem = ({item}: {item: FollowItem}) => (
|
||||
<User key={item.did} item={item} />
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.did}
|
||||
did={item.did}
|
||||
declarationCid={item.declaration.cid}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
isFollowedBy={!!item.viewer?.followedBy}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<FlatList
|
||||
|
@ -84,59 +85,7 @@ export const ProfileFollows = observer(function ProfileFollows({
|
|||
)
|
||||
})
|
||||
|
||||
const User = ({item}: {item: FollowItem}) => {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<Link
|
||||
style={[styles.outer, pal.view, pal.border]}
|
||||
href={`/profile/${item.handle}`}
|
||||
title={item.handle}
|
||||
noFeedback>
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<UserAvatar
|
||||
size={40}
|
||||
displayName={item.displayName}
|
||||
handle={item.handle}
|
||||
avatar={
|
||||
item.avatar as
|
||||
| string
|
||||
| undefined /* HACK: type signature is wrong in the api */
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<Text style={[s.bold, pal.text]}>
|
||||
{item.displayName || item.handle}
|
||||
</Text>
|
||||
<Text type="sm" style={[pal.textLight]}>
|
||||
@{item.handle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
layoutAvi: {
|
||||
width: 60,
|
||||
paddingLeft: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
layoutContent: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
footer: {
|
||||
height: 200,
|
||||
paddingTop: 20,
|
||||
|
|
|
@ -13,15 +13,16 @@ import {
|
|||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {BlurView} from '../util/BlurView'
|
||||
import {ProfileViewModel} from '../../../state/models/profile-view'
|
||||
import {useStores} from '../../../state'
|
||||
import {ProfileViewModel} from 'state/models/profile-view'
|
||||
import {useStores} from 'state/index'
|
||||
import {
|
||||
EditProfileModal,
|
||||
ReportAccountModal,
|
||||
ProfileImageLightbox,
|
||||
} from '../../../state/models/shell-ui'
|
||||
import {pluralize, toShareUrl} from '../../../lib/strings'
|
||||
import {s, gradients} from '../../lib/styles'
|
||||
} from 'state/models/shell-ui'
|
||||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {toShareUrl} from 'lib/strings/url-helpers'
|
||||
import {s, gradients} from 'lib/styles'
|
||||
import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
|
@ -29,7 +30,8 @@ import {Text} from '../util/text/Text'
|
|||
import {RichText} from '../util/text/RichText'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {UserBanner} from '../util/UserBanner'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
|
||||
export const ProfileHeader = observer(function ProfileHeader({
|
||||
view,
|
||||
|
@ -40,7 +42,7 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
}) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
|
||||
const {track} = useAnalytics()
|
||||
const onPressBack = () => {
|
||||
store.nav.tab.goBack()
|
||||
}
|
||||
|
@ -53,7 +55,7 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
view?.toggleFollowing().then(
|
||||
() => {
|
||||
Toast.show(
|
||||
`${view.myState.follow ? 'Following' : 'No longer following'} ${
|
||||
`${view.viewer.following ? 'Following' : 'No longer following'} ${
|
||||
view.displayName || view.handle
|
||||
}`,
|
||||
)
|
||||
|
@ -62,18 +64,23 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
)
|
||||
}
|
||||
const onPressEditProfile = () => {
|
||||
track('ProfileHeader:EditProfileButtonClicked')
|
||||
store.shell.openModal(new EditProfileModal(view, onRefreshAll))
|
||||
}
|
||||
const onPressFollowers = () => {
|
||||
track('ProfileHeader:FollowersButtonClicked')
|
||||
store.nav.navigate(`/profile/${view.handle}/followers`)
|
||||
}
|
||||
const onPressFollows = () => {
|
||||
track('ProfileHeader:FollowsButtonClicked')
|
||||
store.nav.navigate(`/profile/${view.handle}/follows`)
|
||||
}
|
||||
const onPressShare = () => {
|
||||
track('ProfileHeader:ShareButtonClicked')
|
||||
Share.share({url: toShareUrl(`/profile/${view.handle}`)})
|
||||
}
|
||||
const onPressMuteAccount = async () => {
|
||||
track('ProfileHeader:MuteAccountButtonClicked')
|
||||
try {
|
||||
await view.muteAccount()
|
||||
Toast.show('Account muted')
|
||||
|
@ -83,6 +90,7 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
}
|
||||
}
|
||||
const onPressUnmuteAccount = async () => {
|
||||
track('ProfileHeader:UnmuteAccountButtonClicked')
|
||||
try {
|
||||
await view.unmuteAccount()
|
||||
Toast.show('Account unmuted')
|
||||
|
@ -92,6 +100,7 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
}
|
||||
}
|
||||
const onPressReportAccount = () => {
|
||||
track('ProfileHeader:ReportAccountButtonClicked')
|
||||
store.shell.openModal(new ReportAccountModal(view.did))
|
||||
}
|
||||
|
||||
|
@ -110,7 +119,7 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
<LoadingPlaceholder width={100} height={31} style={styles.br50} />
|
||||
</View>
|
||||
<View style={styles.displayNameLine}>
|
||||
<Text type="title-xl" style={[pal.text, styles.title]}>
|
||||
<Text type="title-2xl" style={[pal.text, styles.title]}>
|
||||
{view.displayName || view.handle}
|
||||
</Text>
|
||||
</View>
|
||||
|
@ -135,8 +144,8 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
let dropdownItems: DropdownItem[] = [{label: 'Share', onPress: onPressShare}]
|
||||
if (!isMe) {
|
||||
dropdownItems.push({
|
||||
label: view.myState.muted ? 'Unmute Account' : 'Mute Account',
|
||||
onPress: view.myState.muted ? onPressUnmuteAccount : onPressMuteAccount,
|
||||
label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
|
||||
onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount,
|
||||
})
|
||||
dropdownItems.push({
|
||||
label: 'Report Account',
|
||||
|
@ -159,7 +168,7 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
</TouchableOpacity>
|
||||
) : (
|
||||
<>
|
||||
{view.myState.follow ? (
|
||||
{store.me.follows.isFollowing(view.did) ? (
|
||||
<TouchableOpacity
|
||||
onPress={onPressToggleFollow}
|
||||
style={[styles.btn, styles.mainBtn, pal.btn]}>
|
||||
|
@ -206,11 +215,18 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
) : undefined}
|
||||
</View>
|
||||
<View style={styles.displayNameLine}>
|
||||
<Text type="title-xl" style={[pal.text, styles.title]}>
|
||||
<Text type="title-2xl" style={[pal.text, styles.title]}>
|
||||
{view.displayName || view.handle}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.handleLine}>
|
||||
{view.viewer.followedBy ? (
|
||||
<View style={[styles.pill, pal.btn, s.mr5]}>
|
||||
<Text type="xs" style={[pal.text]}>
|
||||
Follows you
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
<Text style={pal.textLight}>@{view.handle}</Text>
|
||||
</View>
|
||||
<View style={styles.metricsLine}>
|
||||
|
@ -247,22 +263,21 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{view.description ? (
|
||||
{view.descriptionRichText ? (
|
||||
<RichText
|
||||
style={[styles.description, pal.text]}
|
||||
numberOfLines={15}
|
||||
text={view.description}
|
||||
entities={view.descriptionEntities}
|
||||
richText={view.descriptionRichText}
|
||||
/>
|
||||
) : undefined}
|
||||
{view.myState.muted ? (
|
||||
{view.viewer.muted ? (
|
||||
<View style={[styles.detailLine, pal.btn, s.p5]}>
|
||||
<FontAwesomeIcon
|
||||
icon={['far', 'eye-slash']}
|
||||
style={[pal.text, s.mr5]}
|
||||
/>
|
||||
<Text type="md" style={[s.mr2, pal.text]}>
|
||||
Account muted.
|
||||
Account muted
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
@ -369,6 +384,12 @@ const styles = StyleSheet.create({
|
|||
marginBottom: 5,
|
||||
},
|
||||
|
||||
pill: {
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
|
||||
br40: {borderRadius: 40},
|
||||
br50: {borderRadius: 50},
|
||||
})
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View, ViewProps} from 'react-native'
|
||||
import {addStyle} from '../../lib/addStyle'
|
||||
import {addStyle} from 'lib/styles'
|
||||
|
||||
type BlurViewProps = ViewProps & {
|
||||
blurType?: 'dark' | 'light'
|
||||
|
|
|
@ -6,8 +6,8 @@ import {
|
|||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from './text/Text'
|
||||
import {UserGroupIcon} from '../../lib/icons'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {UserGroupIcon} from 'lib/icons'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
|
|
|
@ -1,41 +1,53 @@
|
|||
import React from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
Animated,
|
||||
GestureResponderEvent,
|
||||
StyleSheet,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
import {colors, gradients} from '../../lib/styles'
|
||||
import {useStores} from '../../../state'
|
||||
import {colors, gradients} from 'lib/styles'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
type OnPress = ((event: GestureResponderEvent) => void) | undefined
|
||||
export const FAB = observer(
|
||||
({icon, onPress}: {icon: IconProp; onPress: OnPress}) => {
|
||||
({
|
||||
testID,
|
||||
icon,
|
||||
onPress,
|
||||
}: {
|
||||
testID?: string
|
||||
icon: IconProp
|
||||
onPress: OnPress
|
||||
}) => {
|
||||
const store = useStores()
|
||||
const interp = useAnimatedValue(0)
|
||||
React.useEffect(() => {
|
||||
Animated.timing(interp, {
|
||||
toValue: store.shell.minimalShellMode ? 1 : 0,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
isInteraction: false,
|
||||
}).start()
|
||||
}, [interp, store.shell.minimalShellMode])
|
||||
const transform = {
|
||||
transform: [{translateY: Animated.multiply(interp, 60)}],
|
||||
}
|
||||
return (
|
||||
<TouchableWithoutFeedback onPress={onPress}>
|
||||
<View
|
||||
style={[
|
||||
styles.outer,
|
||||
store.shell.minimalShellMode ? styles.lower : undefined,
|
||||
]}>
|
||||
<TouchableWithoutFeedback testID={testID} onPress={onPress}>
|
||||
<Animated.View style={[styles.outer, transform]}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={styles.inner}>
|
||||
<FontAwesomeIcon
|
||||
size={24}
|
||||
icon={icon}
|
||||
color={colors.white}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<FontAwesomeIcon size={24} icon={icon} color={colors.white} />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</TouchableWithoutFeedback>
|
||||
)
|
||||
},
|
||||
|
@ -46,16 +58,10 @@ const styles = StyleSheet.create({
|
|||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
right: 22,
|
||||
bottom: 84,
|
||||
bottom: 94,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.3,
|
||||
shadowOffset: {width: 0, height: 1},
|
||||
},
|
||||
lower: {
|
||||
bottom: 34,
|
||||
},
|
||||
inner: {
|
||||
width: 60,
|
||||
|
@ -64,5 +70,4 @@ const styles = StyleSheet.create({
|
|||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
icon: {},
|
||||
})
|
||||
|
|
|
@ -10,9 +10,9 @@ import {
|
|||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {Text} from './text/Text'
|
||||
import {TypographyVariant} from '../../lib/ThemeContext'
|
||||
import {useStores, RootStoreModel} from '../../../state'
|
||||
import {convertBskyAppUrlIfNeeded} from '../../../lib/strings'
|
||||
import {TypographyVariant} from 'lib/ThemeContext'
|
||||
import {useStores, RootStoreModel} from 'state/index'
|
||||
import {convertBskyAppUrlIfNeeded} from 'lib/strings/url-helpers'
|
||||
|
||||
export const Link = observer(function Link({
|
||||
style,
|
||||
|
@ -22,17 +22,21 @@ export const Link = observer(function Link({
|
|||
noFeedback,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
href: string
|
||||
href?: string
|
||||
title?: string
|
||||
children?: React.ReactNode
|
||||
noFeedback?: boolean
|
||||
}) {
|
||||
const store = useStores()
|
||||
const onPress = () => {
|
||||
handleLink(store, href, false)
|
||||
if (href) {
|
||||
handleLink(store, href, false)
|
||||
}
|
||||
}
|
||||
const onLongPress = () => {
|
||||
handleLink(store, href, true)
|
||||
if (href) {
|
||||
handleLink(store, href, true)
|
||||
}
|
||||
}
|
||||
if (noFeedback) {
|
||||
return (
|
||||
|
|
|
@ -4,9 +4,9 @@ import {observer} from 'mobx-react-lite'
|
|||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {Text} from './text/Text'
|
||||
import {colors, gradients} from '../../lib/styles'
|
||||
import {colors, gradients} from 'lib/styles'
|
||||
import {clamp} from 'lodash'
|
||||
import {useStores} from '../../../state'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity} from 'react-native'
|
||||
import {Text} from './text/Text'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
|
||||
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {HeartIcon} from '../../lib/icons'
|
||||
import {s} from '../../lib/styles'
|
||||
import {useTheme} from '../../lib/ThemeContext'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {HeartIcon} from 'lib/icons'
|
||||
import {s} from 'lib/styles'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export function LoadingPlaceholder({
|
||||
width,
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from '@fortawesome/react-native-fontawesome'
|
||||
import RootSiblings from 'react-native-root-siblings'
|
||||
import {Text} from './text/Text'
|
||||
import {colors} from '../../lib/styles'
|
||||
import {colors} from 'lib/styles'
|
||||
|
||||
interface PickerItem {
|
||||
value: string
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
Animated,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
|
@ -12,6 +11,11 @@ import {
|
|||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import ReactNativeHapticFeedback from 'react-native-haptic-feedback'
|
||||
// DISABLED see #135
|
||||
// import {
|
||||
// TriggerableAnimated,
|
||||
// TriggerableAnimatedRef,
|
||||
// } from './anim/TriggerableAnimated'
|
||||
import {Text} from './text/Text'
|
||||
import {PostDropdownBtn} from './forms/DropdownButton'
|
||||
import {
|
||||
|
@ -19,12 +23,13 @@ import {
|
|||
HeartIconSolid,
|
||||
RepostIcon,
|
||||
CommentBottomArrow,
|
||||
} from '../../lib/icons'
|
||||
import {s, colors} from '../../lib/styles'
|
||||
import {useTheme} from '../../lib/ThemeContext'
|
||||
import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
|
||||
} from 'lib/icons'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
|
||||
interface PostCtrlsOpts {
|
||||
itemUri: string
|
||||
itemCid: string
|
||||
itemHref: string
|
||||
itemTitle: string
|
||||
isAuthor: boolean
|
||||
|
@ -36,13 +41,49 @@ interface PostCtrlsOpts {
|
|||
isReposted: boolean
|
||||
isUpvoted: boolean
|
||||
onPressReply: () => void
|
||||
onPressToggleRepost: () => void
|
||||
onPressToggleUpvote: () => void
|
||||
onPressToggleRepost: () => Promise<void>
|
||||
onPressToggleUpvote: () => Promise<void>
|
||||
onCopyPostText: () => void
|
||||
onDeletePost: () => void
|
||||
}
|
||||
|
||||
const HITSLOP = {top: 2, left: 2, bottom: 2, right: 2}
|
||||
const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5}
|
||||
|
||||
// DISABLED see #135
|
||||
/*
|
||||
function ctrlAnimStart(interp: Animated.Value) {
|
||||
return Animated.sequence([
|
||||
Animated.timing(interp, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.delay(50),
|
||||
Animated.timing(interp, {
|
||||
toValue: 0,
|
||||
duration: 20,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
function ctrlAnimStyle(interp: Animated.Value) {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
scale: interp.interpolate({
|
||||
inputRange: [0, 1.0],
|
||||
outputRange: [1.0, 4.0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
opacity: interp.interpolate({
|
||||
inputRange: [0, 1.0],
|
||||
outputRange: [1.0, 0.0],
|
||||
}),
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
export function PostCtrls(opts: PostCtrlsOpts) {
|
||||
const theme = useTheme()
|
||||
|
@ -51,76 +92,59 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
|||
color: theme.palette.default.postCtrl,
|
||||
}),
|
||||
[theme],
|
||||
)
|
||||
const interp1 = useAnimatedValue(0)
|
||||
const interp2 = useAnimatedValue(0)
|
||||
|
||||
const anim1Style = {
|
||||
transform: [
|
||||
{
|
||||
scale: interp1.interpolate({
|
||||
inputRange: [0, 1.0],
|
||||
outputRange: [1.0, 4.0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
opacity: interp1.interpolate({
|
||||
inputRange: [0, 1.0],
|
||||
outputRange: [1.0, 0.0],
|
||||
}),
|
||||
}
|
||||
const anim2Style = {
|
||||
transform: [
|
||||
{
|
||||
scale: interp2.interpolate({
|
||||
inputRange: [0, 1.0],
|
||||
outputRange: [1.0, 4.0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
opacity: interp2.interpolate({
|
||||
inputRange: [0, 1.0],
|
||||
outputRange: [1.0, 0.0],
|
||||
}),
|
||||
}
|
||||
|
||||
) as StyleProp<ViewStyle>
|
||||
const [repostMod, setRepostMod] = React.useState<number>(0)
|
||||
const [likeMod, setLikeMod] = React.useState<number>(0)
|
||||
// DISABLED see #135
|
||||
// const repostRef = React.useRef<TriggerableAnimatedRef | null>(null)
|
||||
// const likeRef = React.useRef<TriggerableAnimatedRef | null>(null)
|
||||
const onPressToggleRepostWrapper = () => {
|
||||
if (!opts.isReposted) {
|
||||
ReactNativeHapticFeedback.trigger('impactMedium')
|
||||
Animated.sequence([
|
||||
Animated.timing(interp1, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.delay(100),
|
||||
Animated.timing(interp1, {
|
||||
toValue: 0,
|
||||
duration: 20,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
setRepostMod(1)
|
||||
opts
|
||||
.onPressToggleRepost()
|
||||
.catch(_e => undefined)
|
||||
.then(() => setRepostMod(0))
|
||||
// DISABLED see #135
|
||||
// repostRef.current?.trigger(
|
||||
// {start: ctrlAnimStart, style: ctrlAnimStyle},
|
||||
// async () => {
|
||||
// await opts.onPressToggleRepost().catch(_e => undefined)
|
||||
// setRepostMod(0)
|
||||
// },
|
||||
// )
|
||||
} else {
|
||||
setRepostMod(-1)
|
||||
opts
|
||||
.onPressToggleRepost()
|
||||
.catch(_e => undefined)
|
||||
.then(() => setRepostMod(0))
|
||||
}
|
||||
opts.onPressToggleRepost()
|
||||
}
|
||||
const onPressToggleUpvoteWrapper = () => {
|
||||
if (!opts.isUpvoted) {
|
||||
ReactNativeHapticFeedback.trigger('impactMedium')
|
||||
Animated.sequence([
|
||||
Animated.timing(interp2, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.delay(100),
|
||||
Animated.timing(interp2, {
|
||||
toValue: 0,
|
||||
duration: 20,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
setLikeMod(1)
|
||||
opts
|
||||
.onPressToggleUpvote()
|
||||
.catch(_e => undefined)
|
||||
.then(() => setLikeMod(0))
|
||||
// DISABLED see #135
|
||||
// likeRef.current?.trigger(
|
||||
// {start: ctrlAnimStart, style: ctrlAnimStyle},
|
||||
// async () => {
|
||||
// await opts.onPressToggleUpvote().catch(_e => undefined)
|
||||
// setLikeMod(0)
|
||||
// },
|
||||
// )
|
||||
} else {
|
||||
setLikeMod(-1)
|
||||
opts
|
||||
.onPressToggleUpvote()
|
||||
.catch(_e => undefined)
|
||||
.then(() => setLikeMod(0))
|
||||
}
|
||||
opts.onPressToggleUpvote()
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -147,7 +171,17 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
|||
hitSlop={HITSLOP}
|
||||
onPress={onPressToggleRepostWrapper}
|
||||
style={styles.ctrl}>
|
||||
<Animated.View style={anim1Style}>
|
||||
<RepostIcon
|
||||
style={
|
||||
opts.isReposted || repostMod > 0
|
||||
? (styles.ctrlIconReposted as StyleProp<ViewStyle>)
|
||||
: defaultCtrlColor
|
||||
}
|
||||
strokeWidth={2.4}
|
||||
size={opts.big ? 24 : 20}
|
||||
/>
|
||||
{
|
||||
undefined /*DISABLED see #135 <TriggerableAnimated ref={repostRef}>
|
||||
<RepostIcon
|
||||
style={
|
||||
(opts.isReposted
|
||||
|
@ -157,15 +191,16 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
|||
strokeWidth={2.4}
|
||||
size={opts.big ? 24 : 20}
|
||||
/>
|
||||
</Animated.View>
|
||||
</TriggerableAnimated>*/
|
||||
}
|
||||
{typeof opts.repostCount !== 'undefined' ? (
|
||||
<Text
|
||||
style={
|
||||
opts.isReposted
|
||||
opts.isReposted || repostMod > 0
|
||||
? [s.bold, s.green3, s.f15, s.ml5]
|
||||
: [defaultCtrlColor, s.f15, s.ml5]
|
||||
}>
|
||||
{opts.repostCount}
|
||||
{opts.repostCount + repostMod}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
|
@ -175,8 +210,21 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
|||
style={styles.ctrl}
|
||||
hitSlop={HITSLOP}
|
||||
onPress={onPressToggleUpvoteWrapper}>
|
||||
<Animated.View style={anim2Style}>
|
||||
{opts.isUpvoted ? (
|
||||
{opts.isUpvoted || likeMod > 0 ? (
|
||||
<HeartIconSolid
|
||||
style={styles.ctrlIconUpvoted as StyleProp<ViewStyle>}
|
||||
size={opts.big ? 22 : 16}
|
||||
/>
|
||||
) : (
|
||||
<HeartIcon
|
||||
style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]}
|
||||
strokeWidth={3}
|
||||
size={opts.big ? 20 : 16}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
undefined /*DISABLED see #135 <TriggerableAnimated ref={likeRef}>
|
||||
{opts.isUpvoted || likeMod > 0 ? (
|
||||
<HeartIconSolid
|
||||
style={styles.ctrlIconUpvoted as ViewStyle}
|
||||
size={opts.big ? 22 : 16}
|
||||
|
@ -191,15 +239,16 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
|||
size={opts.big ? 20 : 16}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</TriggerableAnimated>*/
|
||||
}
|
||||
{typeof opts.upvoteCount !== 'undefined' ? (
|
||||
<Text
|
||||
style={
|
||||
opts.isUpvoted
|
||||
opts.isUpvoted || likeMod > 0
|
||||
? [s.bold, s.red3, s.f15, s.ml5]
|
||||
: [defaultCtrlColor, s.f15, s.ml5]
|
||||
}>
|
||||
{opts.upvoteCount}
|
||||
{opts.upvoteCount + likeMod}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
|
@ -208,6 +257,8 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
|||
{opts.big ? undefined : (
|
||||
<PostDropdownBtn
|
||||
style={styles.ctrl}
|
||||
itemUri={opts.itemUri}
|
||||
itemCid={opts.itemCid}
|
||||
itemHref={opts.itemHref}
|
||||
itemTitle={opts.itemTitle}
|
||||
isAuthor={opts.isAuthor}
|
||||
|
|
69
src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx
Normal file
69
src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import React from 'react'
|
||||
import {Text} from '../text/Text'
|
||||
import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external'
|
||||
|
||||
const ExternalLinkEmbed = ({
|
||||
link,
|
||||
onImagePress,
|
||||
imageChild,
|
||||
}: {
|
||||
link: PresentedExternal
|
||||
onImagePress?: () => void
|
||||
imageChild?: React.ReactNode
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<>
|
||||
{link.thumb ? (
|
||||
<AutoSizedImage
|
||||
uri={link.thumb}
|
||||
style={styles.extImage}
|
||||
onPress={onImagePress}>
|
||||
{imageChild}
|
||||
</AutoSizedImage>
|
||||
) : undefined}
|
||||
<View style={styles.extInner}>
|
||||
<Text type="md-bold" numberOfLines={2} style={[pal.text]}>
|
||||
{link.title || link.uri}
|
||||
</Text>
|
||||
<Text
|
||||
type="sm"
|
||||
numberOfLines={1}
|
||||
style={[pal.textLight, styles.extUri]}>
|
||||
{link.uri}
|
||||
</Text>
|
||||
{link.description ? (
|
||||
<Text
|
||||
type="sm"
|
||||
numberOfLines={2}
|
||||
style={[pal.text, styles.extDescription]}>
|
||||
{link.description}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
extInner: {
|
||||
padding: 10,
|
||||
},
|
||||
extImage: {
|
||||
borderTopLeftRadius: 6,
|
||||
borderTopRightRadius: 6,
|
||||
width: '100%',
|
||||
maxHeight: 200,
|
||||
},
|
||||
extUri: {
|
||||
marginTop: 2,
|
||||
},
|
||||
extDescription: {
|
||||
marginTop: 4,
|
||||
},
|
||||
})
|
||||
|
||||
export default ExternalLinkEmbed
|
119
src/view/com/util/PostEmbeds/YoutubeEmbed.tsx
Normal file
119
src/view/com/util/PostEmbeds/YoutubeEmbed.tsx
Normal file
|
@ -0,0 +1,119 @@
|
|||
import React, {useEffect} from 'react'
|
||||
import {useState} from 'react'
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
TouchableWithoutFeedback,
|
||||
EmitterSubscription,
|
||||
} from 'react-native'
|
||||
import YoutubePlayer from 'react-native-youtube-iframe'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import ExternalLinkEmbed from './ExternalLinkEmbed'
|
||||
import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
const YoutubeEmbed = ({
|
||||
link,
|
||||
videoId,
|
||||
}: {
|
||||
videoId: string
|
||||
link: PresentedExternal
|
||||
}) => {
|
||||
const store = useStores()
|
||||
const [displayVideoPlayer, setDisplayVideoPlayer] = useState(false)
|
||||
const [playerDimensions, setPlayerDimensions] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
})
|
||||
const pal = usePalette('default')
|
||||
const handlePlayButtonPressed = () => {
|
||||
setDisplayVideoPlayer(true)
|
||||
}
|
||||
const handleOnLayout = (event: {
|
||||
nativeEvent: {layout: {width: any; height: any}}
|
||||
}) => {
|
||||
setPlayerDimensions({
|
||||
width: event.nativeEvent.layout.width,
|
||||
height: event.nativeEvent.layout.height,
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
let sub: EmitterSubscription
|
||||
if (displayVideoPlayer) {
|
||||
sub = store.onNavigation(() => {
|
||||
setDisplayVideoPlayer(false)
|
||||
})
|
||||
}
|
||||
return () => sub && sub.remove()
|
||||
}, [displayVideoPlayer, store])
|
||||
|
||||
const imageChild = (
|
||||
<Pressable onPress={handlePlayButtonPressed} style={styles.playButton}>
|
||||
<FontAwesomeIcon icon="play" size={24} color="white" />
|
||||
</Pressable>
|
||||
)
|
||||
|
||||
if (!displayVideoPlayer) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.extOuter, pal.view, pal.border]}
|
||||
onLayout={handleOnLayout}>
|
||||
<ExternalLinkEmbed
|
||||
link={link}
|
||||
onImagePress={handlePlayButtonPressed}
|
||||
imageChild={imageChild}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const height = (playerDimensions.width / 16) * 9
|
||||
const noop = () => {}
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback onPress={noop}>
|
||||
<View>
|
||||
{/* Removing the outter View will make tap events propagate to parents */}
|
||||
<YoutubePlayer
|
||||
initialPlayerParams={{
|
||||
modestbranding: true,
|
||||
}}
|
||||
webViewProps={{
|
||||
startInLoadingState: true,
|
||||
}}
|
||||
height={height}
|
||||
videoId={videoId}
|
||||
webViewStyle={styles.webView}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
extOuter: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
playButton: {
|
||||
position: 'absolute',
|
||||
alignSelf: 'center',
|
||||
alignItems: 'center',
|
||||
top: '44%',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'black',
|
||||
padding: 10,
|
||||
borderRadius: 50,
|
||||
opacity: 0.8,
|
||||
},
|
||||
webView: {
|
||||
alignItems: 'center',
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
export default YoutubeEmbed
|
|
@ -1,16 +1,22 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {
|
||||
StyleSheet,
|
||||
StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
Image as RNImage,
|
||||
} from 'react-native'
|
||||
import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from './text/Text'
|
||||
import {AutoSizedImage} from './images/AutoSizedImage'
|
||||
import {ImageLayoutGrid} from './images/ImageLayoutGrid'
|
||||
import {ImagesLightbox} from '../../../state/models/shell-ui'
|
||||
import {useStores} from '../../../state'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {gradients} from '../../lib/styles'
|
||||
import {saveImageModal} from '../../../lib/images'
|
||||
import {Link} from '../Link'
|
||||
import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||
import {ImagesLightbox} from 'state/models/shell-ui'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {saveImageModal} from 'lib/images'
|
||||
import YoutubeEmbed from './YoutubeEmbed'
|
||||
import ExternalLinkEmbed from './ExternalLinkEmbed'
|
||||
import {getYoutubeVideoId} from 'lib/strings/url-helpers'
|
||||
|
||||
type Embed =
|
||||
| AppBskyEmbedImages.Presented
|
||||
|
@ -35,6 +41,16 @@ export function PostEmbeds({
|
|||
const onLongPress = (index: number) => {
|
||||
saveImageModal({uri: uris[index]})
|
||||
}
|
||||
const onPressIn = (index: number) => {
|
||||
const firstImageToShow = uris[index]
|
||||
RNImage.prefetch(firstImageToShow)
|
||||
uris.forEach(uri => {
|
||||
if (firstImageToShow !== uri) {
|
||||
// First image already prefeched above
|
||||
RNImage.prefetch(uri)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (embed.images.length === 4) {
|
||||
return (
|
||||
|
@ -44,6 +60,7 @@ export function PostEmbeds({
|
|||
uris={embed.images.map(img => img.thumb)}
|
||||
onPress={openLightbox}
|
||||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
@ -55,6 +72,7 @@ export function PostEmbeds({
|
|||
uris={embed.images.map(img => img.thumb)}
|
||||
onPress={openLightbox}
|
||||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
@ -66,6 +84,7 @@ export function PostEmbeds({
|
|||
uris={embed.images.map(img => img.thumb)}
|
||||
onPress={openLightbox}
|
||||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
@ -76,7 +95,8 @@ export function PostEmbeds({
|
|||
uri={embed.images[0].thumb}
|
||||
onPress={() => openLightbox(0)}
|
||||
onLongPress={() => onLongPress(0)}
|
||||
containerStyle={styles.singleImage}
|
||||
onPressIn={() => onPressIn(0)}
|
||||
style={styles.singleImage}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
@ -85,40 +105,18 @@ export function PostEmbeds({
|
|||
}
|
||||
if (AppBskyEmbedExternal.isPresented(embed)) {
|
||||
const link = embed.external
|
||||
const youtubeVideoId = getYoutubeVideoId(link.uri)
|
||||
|
||||
if (youtubeVideoId) {
|
||||
return <YoutubeEmbed videoId={youtubeVideoId} link={link} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
style={[styles.extOuter, pal.view, pal.border, style]}
|
||||
href={link.uri}
|
||||
noFeedback>
|
||||
{link.thumb ? (
|
||||
<AutoSizedImage uri={link.thumb} containerStyle={styles.extImage} />
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={[gradients.blueDark.start, gradients.blueDark.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[styles.extImage, styles.extImageFallback]}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.extInner}>
|
||||
<Text type="md-bold" numberOfLines={2} style={[pal.text]}>
|
||||
{link.title || link.uri}
|
||||
</Text>
|
||||
<Text
|
||||
type="sm"
|
||||
numberOfLines={1}
|
||||
style={[pal.textLight, styles.extUri]}>
|
||||
{link.uri}
|
||||
</Text>
|
||||
{link.description ? (
|
||||
<Text
|
||||
type="sm"
|
||||
numberOfLines={2}
|
||||
style={[pal.text, styles.extDescription]}>
|
||||
{link.description}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
<ExternalLinkEmbed link={link} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
@ -131,28 +129,11 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
singleImage: {
|
||||
borderRadius: 8,
|
||||
maxHeight: 500,
|
||||
},
|
||||
extOuter: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
extInner: {
|
||||
padding: 10,
|
||||
},
|
||||
extImage: {
|
||||
borderTopLeftRadius: 6,
|
||||
borderTopRightRadius: 6,
|
||||
width: '100%',
|
||||
maxHeight: 200,
|
||||
},
|
||||
extImageFallback: {
|
||||
height: 160,
|
||||
},
|
||||
extUri: {
|
||||
marginTop: 2,
|
||||
},
|
||||
extDescription: {
|
||||
marginTop: 4,
|
||||
},
|
||||
})
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react'
|
||||
import {Platform, StyleSheet, View} from 'react-native'
|
||||
import {Text} from './text/Text'
|
||||
import {ago} from '../../../lib/strings'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {ago} from 'lib/strings/time'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
interface PostMetaOpts {
|
||||
authorHandle: string
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
View,
|
||||
} from 'react-native'
|
||||
import {Text} from './text/Text'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
interface Layout {
|
||||
x: number
|
||||
|
|
|
@ -1,15 +1,23 @@
|
|||
import React, {useCallback} from 'react'
|
||||
import {Alert, Image, StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import Svg, {Circle, Text, Defs, LinearGradient, Stop} from 'react-native-svg'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
import {HighPriorityImage} from 'view/com/util/images/Image'
|
||||
import {
|
||||
openCamera,
|
||||
openCropper,
|
||||
openPicker,
|
||||
PickedMedia,
|
||||
} from './images/image-crop-picker/ImageCropPicker'
|
||||
import {useStores} from '../../../state'
|
||||
import {colors, gradients} from '../../lib/styles'
|
||||
import {
|
||||
requestPhotoAccessIfNeeded,
|
||||
requestCameraAccessIfNeeded,
|
||||
} from 'lib/permissions'
|
||||
import {useStores} from 'state/index'
|
||||
import {colors, gradients} from 'lib/styles'
|
||||
import {DropdownButton} from './forms/DropdownButton'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export function UserAvatar({
|
||||
size,
|
||||
|
@ -25,40 +33,9 @@ export function UserAvatar({
|
|||
onSelectNewAvatar?: (img: PickedMedia) => void
|
||||
}) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const initials = getInitials(displayName || handle)
|
||||
|
||||
const handleEditAvatar = useCallback(() => {
|
||||
Alert.alert('Select upload method', '', [
|
||||
{
|
||||
text: 'Take a new photo',
|
||||
onPress: () => {
|
||||
openCamera(store, {
|
||||
mediaType: 'photo',
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
cropperCircleOverlay: true,
|
||||
}).then(onSelectNewAvatar)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Select from gallery',
|
||||
onPress: () => {
|
||||
openPicker(store, {
|
||||
mediaType: 'photo',
|
||||
}).then(async items => {
|
||||
await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path: items[0].path,
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
cropperCircleOverlay: true,
|
||||
}).then(onSelectNewAvatar)
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
}, [store, onSelectNewAvatar])
|
||||
|
||||
const renderSvg = (svgSize: number, svgInitials: string) => (
|
||||
<Svg width={svgSize} height={svgSize} viewBox="0 0 100 100">
|
||||
<Defs>
|
||||
|
@ -80,11 +57,65 @@ export function UserAvatar({
|
|||
</Svg>
|
||||
)
|
||||
|
||||
const dropdownItems = [
|
||||
{
|
||||
label: 'Camera',
|
||||
icon: 'camera' as IconProp,
|
||||
onPress: async () => {
|
||||
if (!(await requestCameraAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
onSelectNewAvatar?.(
|
||||
await openCamera(store, {
|
||||
mediaType: 'photo',
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
cropperCircleOverlay: true,
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Library',
|
||||
icon: 'image' as IconProp,
|
||||
onPress: async () => {
|
||||
if (!(await requestPhotoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
const items = await openPicker(store, {
|
||||
mediaType: 'photo',
|
||||
})
|
||||
onSelectNewAvatar?.(
|
||||
await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path: items[0].path,
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
cropperCircleOverlay: true,
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
// TODO: Remove avatar https://github.com/bluesky-social/social-app/issues/122
|
||||
// {
|
||||
// label: 'Remove',
|
||||
// icon: ['far', 'trash-can'],
|
||||
// onPress: () => {
|
||||
// // Remove avatar API call
|
||||
// },
|
||||
// },
|
||||
]
|
||||
// onSelectNewAvatar is only passed as prop on the EditProfile component
|
||||
return onSelectNewAvatar ? (
|
||||
<TouchableOpacity onPress={handleEditAvatar}>
|
||||
<DropdownButton
|
||||
type="bare"
|
||||
items={dropdownItems}
|
||||
openToRight
|
||||
rightOffset={-10}
|
||||
bottomOffset={-10}
|
||||
menuWidth={170}>
|
||||
{avatar ? (
|
||||
<Image
|
||||
<HighPriorityImage
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
|
@ -95,16 +126,16 @@ export function UserAvatar({
|
|||
) : (
|
||||
renderSvg(size, initials)
|
||||
)}
|
||||
<View style={styles.editButtonContainer}>
|
||||
<View style={[styles.editButtonContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="camera"
|
||||
size={12}
|
||||
style={{color: colors.white}}
|
||||
color={pal.text.color as string}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</DropdownButton>
|
||||
) : avatar ? (
|
||||
<Image
|
||||
<HighPriorityImage
|
||||
style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
|
||||
resizeMode="stretch"
|
||||
source={{uri: avatar}}
|
||||
|
|
|
@ -1,15 +1,23 @@
|
|||
import React, {useCallback} from 'react'
|
||||
import {StyleSheet, View, TouchableOpacity, Alert, Image} from 'react-native'
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {colors, gradients} from '../../lib/styles'
|
||||
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
import Image from 'view/com/util/images/Image'
|
||||
import {colors, gradients} from 'lib/styles'
|
||||
import {
|
||||
openCamera,
|
||||
openCropper,
|
||||
openPicker,
|
||||
PickedMedia,
|
||||
} from './images/image-crop-picker/ImageCropPicker'
|
||||
import {useStores} from '../../../state'
|
||||
import {useStores} from 'state/index'
|
||||
import {
|
||||
requestPhotoAccessIfNeeded,
|
||||
requestCameraAccessIfNeeded,
|
||||
} from 'lib/permissions'
|
||||
import {DropdownButton} from './forms/DropdownButton'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export function UserBanner({
|
||||
banner,
|
||||
|
@ -19,39 +27,57 @@ export function UserBanner({
|
|||
onSelectNewBanner?: (img: PickedMedia) => void
|
||||
}) {
|
||||
const store = useStores()
|
||||
const handleEditBanner = useCallback(() => {
|
||||
Alert.alert('Select upload method', '', [
|
||||
{
|
||||
text: 'Take a new photo',
|
||||
onPress: () => {
|
||||
openCamera(store, {
|
||||
const pal = usePalette('default')
|
||||
const dropdownItems = [
|
||||
{
|
||||
label: 'Camera',
|
||||
icon: 'camera' as IconProp,
|
||||
onPress: async () => {
|
||||
if (!(await requestCameraAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
onSelectNewBanner?.(
|
||||
await openCamera(store, {
|
||||
mediaType: 'photo',
|
||||
// compressImageMaxWidth: 3000, TODO needed?
|
||||
width: 3000,
|
||||
// compressImageMaxHeight: 1000, TODO needed?
|
||||
height: 1000,
|
||||
}).then(onSelectNewBanner)
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
text: 'Select from gallery',
|
||||
onPress: () => {
|
||||
openPicker(store, {
|
||||
},
|
||||
{
|
||||
label: 'Library',
|
||||
icon: 'image' as IconProp,
|
||||
onPress: async () => {
|
||||
if (!(await requestPhotoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
const items = await openPicker(store, {
|
||||
mediaType: 'photo',
|
||||
})
|
||||
onSelectNewBanner?.(
|
||||
await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
}).then(async items => {
|
||||
await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path: items[0].path,
|
||||
// compressImageMaxWidth: 3000, TODO needed?
|
||||
width: 3000,
|
||||
// compressImageMaxHeight: 1000, TODO needed?
|
||||
height: 1000,
|
||||
}).then(onSelectNewBanner)
|
||||
})
|
||||
},
|
||||
path: items[0].path,
|
||||
// compressImageMaxWidth: 3000, TODO needed?
|
||||
width: 3000,
|
||||
// compressImageMaxHeight: 1000, TODO needed?
|
||||
height: 1000,
|
||||
}),
|
||||
)
|
||||
},
|
||||
])
|
||||
}, [store, onSelectNewBanner])
|
||||
},
|
||||
// TODO: Remove banner https://github.com/bluesky-social/social-app/issues/122
|
||||
// {
|
||||
// label: 'Remove',
|
||||
// icon: ['far', 'trash-can'],
|
||||
// onPress: () => {
|
||||
// // Remove banner api call
|
||||
// },
|
||||
// },
|
||||
]
|
||||
|
||||
const renderSvg = () => (
|
||||
<Svg width="100%" height="150" viewBox="50 0 200 100">
|
||||
|
@ -72,20 +98,27 @@ export function UserBanner({
|
|||
|
||||
// setUserBanner is only passed as prop on the EditProfile component
|
||||
return onSelectNewBanner ? (
|
||||
<TouchableOpacity onPress={handleEditBanner}>
|
||||
<DropdownButton
|
||||
type="bare"
|
||||
items={dropdownItems}
|
||||
openToRight
|
||||
rightOffset={-200}
|
||||
bottomOffset={-10}
|
||||
menuWidth={170}>
|
||||
{banner ? (
|
||||
<Image style={styles.bannerImage} source={{uri: banner}} />
|
||||
) : (
|
||||
renderSvg()
|
||||
)}
|
||||
<View style={styles.editButtonContainer}>
|
||||
<View style={[styles.editButtonContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="camera"
|
||||
size={12}
|
||||
style={{color: colors.white}}
|
||||
color={pal.text.color as string}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</DropdownButton>
|
||||
) : banner ? (
|
||||
<Image
|
||||
style={styles.bannerImage}
|
||||
|
|
|
@ -4,8 +4,8 @@ import {StyleProp, StyleSheet, TextStyle} from 'react-native'
|
|||
import {Link} from './Link'
|
||||
import {Text} from './text/Text'
|
||||
import {LoadingPlaceholder} from './LoadingPlaceholder'
|
||||
import {useStores} from '../../../state'
|
||||
import {TypographyVariant} from '../../lib/ThemeContext'
|
||||
import {useStores} from 'state/index'
|
||||
import {TypographyVariant} from 'lib/ThemeContext'
|
||||
|
||||
export function UserInfoText({
|
||||
type = 'md',
|
||||
|
|
|
@ -1,56 +1,40 @@
|
|||
import React from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {CenteredView} from './Views'
|
||||
import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {UserAvatar} from './UserAvatar'
|
||||
import {Text} from './text/Text'
|
||||
import {MagnifyingGlassIcon} from '../../lib/icons'
|
||||
import {useStores} from '../../../state'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {colors} from '../../lib/styles'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
|
||||
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
||||
const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
|
||||
|
||||
export const ViewHeader = observer(function ViewHeader({
|
||||
title,
|
||||
subtitle,
|
||||
canGoBack,
|
||||
hideOnScroll,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
canGoBack?: boolean
|
||||
hideOnScroll?: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const {track} = useAnalytics()
|
||||
const onPressBack = () => {
|
||||
store.nav.tab.goBack()
|
||||
}
|
||||
const onPressMenu = () => {
|
||||
track('ViewHeader:MenuButtonClicked')
|
||||
store.shell.setMainMenuOpen(true)
|
||||
}
|
||||
const onPressSearch = () => {
|
||||
store.nav.navigate('/search')
|
||||
}
|
||||
const onPressReconnect = () => {
|
||||
store.session.connect().catch(e => {
|
||||
store.log.warn('Failed to reconnect to server', e)
|
||||
})
|
||||
}
|
||||
if (typeof canGoBack === 'undefined') {
|
||||
canGoBack = store.nav.tab.canGoBack
|
||||
}
|
||||
return (
|
||||
<CenteredView style={[styles.header, pal.view]}>
|
||||
<Container hideOnScroll={hideOnScroll || false}>
|
||||
<TouchableOpacity
|
||||
testID="viewHeaderBackOrMenuBtn"
|
||||
onPress={canGoBack ? onPressBack : onPressMenu}
|
||||
|
@ -75,48 +59,57 @@ export const ViewHeader = observer(function ViewHeader({
|
|||
<Text type="title" style={[pal.text, styles.title]}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text
|
||||
type="title-sm"
|
||||
style={[styles.subtitle, pal.textLight]}
|
||||
numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={onPressSearch}
|
||||
hitSlop={HITSLOP}
|
||||
style={styles.btn}>
|
||||
<MagnifyingGlassIcon size={21} strokeWidth={3} style={pal.text} />
|
||||
</TouchableOpacity>
|
||||
{!store.session.online ? (
|
||||
<TouchableOpacity style={styles.btn} onPress={onPressReconnect}>
|
||||
{store.session.attemptingConnect ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<>
|
||||
<FontAwesomeIcon
|
||||
icon="signal"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
size={16}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
icon="x"
|
||||
style={[
|
||||
styles.littleXIcon,
|
||||
{backgroundColor: pal.colors.background},
|
||||
]}
|
||||
size={8}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : undefined}
|
||||
</CenteredView>
|
||||
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
|
||||
const Container = observer(
|
||||
({
|
||||
children,
|
||||
hideOnScroll,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
hideOnScroll: boolean
|
||||
}) => {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const interp = useAnimatedValue(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (store.shell.minimalShellMode) {
|
||||
Animated.timing(interp, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
isInteraction: false,
|
||||
}).start()
|
||||
} else {
|
||||
Animated.timing(interp, {
|
||||
toValue: 0,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
isInteraction: false,
|
||||
}).start()
|
||||
}
|
||||
}, [interp, store.shell.minimalShellMode])
|
||||
const transform = {
|
||||
transform: [{translateY: Animated.multiply(interp, -100)}],
|
||||
}
|
||||
|
||||
if (!hideOnScroll) {
|
||||
return <View style={[styles.header, pal.view]}>{children}</View>
|
||||
}
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.header, pal.view, styles.headerFloating, transform]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
|
@ -125,20 +118,20 @@ const styles = StyleSheet.create({
|
|||
paddingTop: 6,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
headerFloating: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
},
|
||||
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
paddingRight: 10,
|
||||
},
|
||||
title: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
marginLeft: 4,
|
||||
maxWidth: 200,
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
|
||||
backBtn: {
|
||||
width: 30,
|
||||
|
@ -152,19 +145,4 @@ const styles = StyleSheet.create({
|
|||
backIcon: {
|
||||
marginTop: 6,
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 20,
|
||||
marginLeft: 4,
|
||||
},
|
||||
littleXIcon: {
|
||||
color: colors.red3,
|
||||
position: 'absolute',
|
||||
right: 7,
|
||||
bottom: 7,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1,20 +1,12 @@
|
|||
import React from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {CenteredView} from './Views'
|
||||
import {Text} from './text/Text'
|
||||
import {useStores} from '../../../state'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {colors} from '../../lib/styles'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {colors} from 'lib/styles'
|
||||
|
||||
const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
|
||||
|
||||
|
@ -32,11 +24,6 @@ export const ViewHeader = observer(function ViewHeader({
|
|||
const onPressBack = () => {
|
||||
store.nav.tab.goBack()
|
||||
}
|
||||
const onPressReconnect = () => {
|
||||
store.session.connect().catch(e => {
|
||||
store.log.warn('Failed to reconnect to server', e)
|
||||
})
|
||||
}
|
||||
if (typeof canGoBack === 'undefined') {
|
||||
canGoBack = store.nav.tab.canGoBack
|
||||
}
|
||||
|
@ -76,29 +63,6 @@ export const ViewHeader = observer(function ViewHeader({
|
|||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{!store.session.online ? (
|
||||
<TouchableOpacity style={styles.btn} onPress={onPressReconnect}>
|
||||
{store.session.attemptingConnect ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<>
|
||||
<FontAwesomeIcon
|
||||
icon="signal"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
size={16}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
icon="x"
|
||||
style={[
|
||||
styles.littleXIcon,
|
||||
{backgroundColor: pal.colors.background},
|
||||
]}
|
||||
size={8}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : undefined}
|
||||
</CenteredView>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -3,10 +3,10 @@ import {View} from 'react-native'
|
|||
import {Selector} from './Selector'
|
||||
import {HorzSwipe} from './gestures/HorzSwipe'
|
||||
import {FlatList} from './Views'
|
||||
import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
|
||||
import {OnScrollCb} from '../../lib/hooks/useOnMainScroll'
|
||||
import {clamp} from '../../../lib/numbers'
|
||||
import {s} from '../../lib/styles'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
|
||||
import {clamp} from 'lib/numbers'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
const HEADER_ITEM = {_reactKey: '__header__'}
|
||||
const SELECTOR_ITEM = {_reactKey: '__selector__'}
|
||||
|
@ -101,6 +101,7 @@ export function ViewSelector({
|
|||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
contentContainerStyle={s.contentContainer}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
</HorzSwipe>
|
||||
)
|
||||
|
|
|
@ -22,9 +22,8 @@ import {
|
|||
View,
|
||||
ViewProps,
|
||||
} from 'react-native'
|
||||
import {useTheme} from '../../lib/ThemeContext'
|
||||
import {addStyle} from '../../lib/addStyle'
|
||||
import {colors} from '../../lib/styles'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {addStyle, colors} from 'lib/styles'
|
||||
|
||||
export function CenteredView({
|
||||
style,
|
||||
|
|
73
src/view/com/util/anim/TriggerableAnimated.tsx
Normal file
73
src/view/com/util/anim/TriggerableAnimated.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
import React from 'react'
|
||||
import {Animated, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
|
||||
type CreateAnimFn = (interp: Animated.Value) => Animated.CompositeAnimation
|
||||
type FinishCb = () => void
|
||||
|
||||
interface TriggeredAnimation {
|
||||
start: CreateAnimFn
|
||||
style: (
|
||||
interp: Animated.Value,
|
||||
) => Animated.WithAnimatedValue<StyleProp<ViewStyle>>
|
||||
}
|
||||
|
||||
export interface TriggerableAnimatedRef {
|
||||
trigger: (anim: TriggeredAnimation, onFinish?: FinishCb) => void
|
||||
}
|
||||
|
||||
type TriggerableAnimatedProps = React.PropsWithChildren<{}>
|
||||
|
||||
type PropsInner = TriggerableAnimatedProps & {
|
||||
anim: TriggeredAnimation
|
||||
onFinish: () => void
|
||||
}
|
||||
|
||||
export const TriggerableAnimated = React.forwardRef<
|
||||
TriggerableAnimatedRef,
|
||||
TriggerableAnimatedProps
|
||||
>(({children, ...props}, ref) => {
|
||||
const [anim, setAnim] = React.useState<TriggeredAnimation | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const [finishCb, setFinishCb] = React.useState<FinishCb | undefined>(
|
||||
undefined,
|
||||
)
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
trigger(v: TriggeredAnimation, cb?: FinishCb) {
|
||||
setFinishCb(() => cb) // note- wrap in function due to react behaviors around setstate
|
||||
setAnim(v)
|
||||
},
|
||||
}))
|
||||
const onFinish = () => {
|
||||
finishCb?.()
|
||||
setAnim(undefined)
|
||||
setFinishCb(undefined)
|
||||
}
|
||||
return (
|
||||
<View key="triggerable">
|
||||
{anim ? (
|
||||
<AnimatingView anim={anim} onFinish={onFinish} {...props}>
|
||||
{children}
|
||||
</AnimatingView>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
function AnimatingView({
|
||||
anim,
|
||||
onFinish,
|
||||
children,
|
||||
}: React.PropsWithChildren<PropsInner>) {
|
||||
const interp = useAnimatedValue(0)
|
||||
React.useEffect(() => {
|
||||
anim?.start(interp).start(() => {
|
||||
onFinish()
|
||||
})
|
||||
})
|
||||
const animStyle = anim?.style(interp)
|
||||
return <Animated.View style={animStyle}>{children}</Animated.View>
|
||||
}
|
|
@ -11,8 +11,8 @@ import {
|
|||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../text/Text'
|
||||
import {useTheme} from '../../../lib/ThemeContext'
|
||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export function ErrorMessage({
|
||||
message,
|
||||
|
|
|
@ -5,9 +5,9 @@ import {
|
|||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../text/Text'
|
||||
import {colors} from '../../../lib/styles'
|
||||
import {useTheme} from '../../../lib/ThemeContext'
|
||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||
import {colors} from 'lib/styles'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export function ErrorScreen({
|
||||
title,
|
||||
|
|
|
@ -7,8 +7,8 @@ import {
|
|||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {Text} from '../text/Text'
|
||||
import {useTheme} from '../../../lib/ThemeContext'
|
||||
import {choose} from '../../../../lib/functions'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {choose} from 'lib/functions'
|
||||
|
||||
export type ButtonType =
|
||||
| 'primary'
|
||||
|
|
|
@ -13,11 +13,13 @@ import RootSiblings from 'react-native-root-siblings'
|
|||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../text/Text'
|
||||
import {Button, ButtonType} from './Button'
|
||||
import {colors} from '../../../lib/styles'
|
||||
import {toShareUrl} from '../../../../lib/strings'
|
||||
import {useStores} from '../../../../state'
|
||||
import {ReportPostModal, ConfirmModal} from '../../../../state/models/shell-ui'
|
||||
import {TABS_ENABLED} from '../../../../build-flags'
|
||||
import {colors} from 'lib/styles'
|
||||
import {toShareUrl} from 'lib/strings/url-helpers'
|
||||
import {useStores} from 'state/index'
|
||||
import {ReportPostModal, ConfirmModal} from 'state/models/shell-ui'
|
||||
import {TABS_ENABLED} from 'lib/build-flags'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
|
||||
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
||||
|
||||
|
@ -36,6 +38,9 @@ export function DropdownButton({
|
|||
label,
|
||||
menuWidth,
|
||||
children,
|
||||
openToRight = false,
|
||||
rightOffset = 0,
|
||||
bottomOffset = 0,
|
||||
}: {
|
||||
type?: DropdownButtonType
|
||||
style?: StyleProp<ViewStyle>
|
||||
|
@ -43,6 +48,9 @@ export function DropdownButton({
|
|||
label?: string
|
||||
menuWidth?: number
|
||||
children?: React.ReactNode
|
||||
openToRight?: boolean
|
||||
rightOffset?: number
|
||||
bottomOffset?: number
|
||||
}) {
|
||||
const ref = useRef<TouchableOpacity>(null)
|
||||
|
||||
|
@ -59,12 +67,11 @@ export function DropdownButton({
|
|||
if (!menuWidth) {
|
||||
menuWidth = 200
|
||||
}
|
||||
createDropdownMenu(
|
||||
pageX + width - menuWidth,
|
||||
pageY + height,
|
||||
menuWidth,
|
||||
items,
|
||||
)
|
||||
const newX = openToRight
|
||||
? pageX + width + rightOffset
|
||||
: pageX + width - menuWidth
|
||||
const newY = pageY + height + bottomOffset
|
||||
createDropdownMenu(newX, newY, menuWidth, items)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@ -97,6 +104,8 @@ export function DropdownButton({
|
|||
export function PostDropdownBtn({
|
||||
style,
|
||||
children,
|
||||
itemUri,
|
||||
itemCid,
|
||||
itemHref,
|
||||
isAuthor,
|
||||
onCopyPostText,
|
||||
|
@ -104,6 +113,8 @@ export function PostDropdownBtn({
|
|||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
children?: React.ReactNode
|
||||
itemUri: string
|
||||
itemCid: string
|
||||
itemHref: string
|
||||
itemTitle: string
|
||||
isAuthor: boolean
|
||||
|
@ -140,7 +151,7 @@ export function PostDropdownBtn({
|
|||
icon: 'circle-exclamation',
|
||||
label: 'Report post',
|
||||
onPress() {
|
||||
store.shell.openModal(new ReportPostModal(itemHref))
|
||||
store.shell.openModal(new ReportPostModal(itemUri, itemCid))
|
||||
},
|
||||
},
|
||||
isAuthor
|
||||
|
@ -180,24 +191,14 @@ function createDropdownMenu(
|
|||
const onOuterPress = () => sibling.destroy()
|
||||
const sibling = new RootSiblings(
|
||||
(
|
||||
<>
|
||||
<TouchableWithoutFeedback onPress={onOuterPress}>
|
||||
<View style={styles.bg} />
|
||||
</TouchableWithoutFeedback>
|
||||
<View style={[styles.menu, {left: x, top: y, width}]}>
|
||||
{items.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[styles.menuItem]}
|
||||
onPress={() => onPressItem(index)}>
|
||||
{item.icon && (
|
||||
<FontAwesomeIcon style={styles.icon} icon={item.icon} />
|
||||
)}
|
||||
<Text style={styles.label}>{item.label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
<DropdownItems
|
||||
onOuterPress={onOuterPress}
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
items={items}
|
||||
onPressItem={onPressItem}
|
||||
/>
|
||||
),
|
||||
)
|
||||
return sibling
|
||||
|
@ -241,3 +242,55 @@ const styles = StyleSheet.create({
|
|||
fontSize: 18,
|
||||
},
|
||||
})
|
||||
type DropDownItemProps = {
|
||||
onOuterPress: () => void
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
items: DropdownItem[]
|
||||
onPressItem: (index: number) => void
|
||||
}
|
||||
|
||||
const DropdownItems = ({
|
||||
onOuterPress,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
items,
|
||||
onPressItem,
|
||||
}: DropDownItemProps) => {
|
||||
const pal = usePalette('default')
|
||||
const theme = useTheme()
|
||||
const dropDownBackgroundColor =
|
||||
theme.colorScheme === 'dark' ? pal.btn : pal.view
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableWithoutFeedback onPress={onOuterPress}>
|
||||
<View style={[styles.bg]} />
|
||||
</TouchableWithoutFeedback>
|
||||
<View
|
||||
style={[
|
||||
styles.menu,
|
||||
{left: x, top: y, width},
|
||||
dropDownBackgroundColor,
|
||||
]}>
|
||||
{items.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[styles.menuItem]}
|
||||
onPress={() => onPressItem(index)}>
|
||||
{item.icon && (
|
||||
<FontAwesomeIcon
|
||||
style={styles.icon}
|
||||
icon={item.icon}
|
||||
color={pal.text.color as string}
|
||||
/>
|
||||
)}
|
||||
<Text style={[styles.label, pal.text]}>{item.label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,8 +2,8 @@ import React from 'react'
|
|||
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
|
||||
import {Text} from '../text/Text'
|
||||
import {Button, ButtonType} from './Button'
|
||||
import {useTheme} from '../../../lib/ThemeContext'
|
||||
import {choose} from '../../../../lib/functions'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {choose} from 'lib/functions'
|
||||
|
||||
export function RadioButton({
|
||||
type = 'default-light',
|
||||
|
|
|
@ -2,7 +2,7 @@ import React, {useState} from 'react'
|
|||
import {View} from 'react-native'
|
||||
import {RadioButton} from './RadioButton'
|
||||
import {ButtonType} from './Button'
|
||||
import {s} from '../../../lib/styles'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export interface RadioGroupItem {
|
||||
label: string
|
||||
|
|
|
@ -2,9 +2,9 @@ import React from 'react'
|
|||
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
|
||||
import {Text} from '../text/Text'
|
||||
import {Button, ButtonType} from './Button'
|
||||
import {useTheme} from '../../../lib/ThemeContext'
|
||||
import {choose} from '../../../../lib/functions'
|
||||
import {colors} from '../../../lib/styles'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {choose} from 'lib/functions'
|
||||
import {colors} from 'lib/styles'
|
||||
|
||||
export function ToggleButton({
|
||||
type = 'default-light',
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
View,
|
||||
} from 'react-native'
|
||||
import {clamp} from 'lodash'
|
||||
import {s} from '../../../lib/styles'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
interface Props {
|
||||
panX: Animated.Value
|
||||
|
@ -90,6 +90,7 @@ export function HorzSwipe({
|
|||
// swiping right
|
||||
(diffX < 0 && !canSwipeRight)
|
||||
) {
|
||||
panX.setValue(0)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -119,6 +120,7 @@ export function HorzSwipe({
|
|||
toValue: final,
|
||||
duration: 100,
|
||||
useNativeDriver,
|
||||
isInteraction: false,
|
||||
}).start(() => {
|
||||
onSwipeEnd?.(final)
|
||||
panX.flattenOffset()
|
||||
|
@ -130,6 +132,7 @@ export function HorzSwipe({
|
|||
toValue: 0,
|
||||
duration: 100,
|
||||
useNativeDriver,
|
||||
isInteraction: false,
|
||||
}).start(() => {
|
||||
panX.flattenOffset()
|
||||
panX.setValue(0)
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
View,
|
||||
} from 'react-native'
|
||||
import {clamp} from 'lodash'
|
||||
import {s} from '../../../lib/styles'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export enum Dir {
|
||||
None,
|
||||
|
|
|
@ -1,125 +1,59 @@
|
|||
import React, {useState, useEffect} from 'react'
|
||||
import {
|
||||
Image,
|
||||
ImageStyle,
|
||||
LayoutChangeEvent,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {Text} from '../text/Text'
|
||||
import {useTheme} from '../../../lib/ThemeContext'
|
||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||
import {DELAY_PRESS_IN} from './constants'
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native'
|
||||
import Image, {OnLoadEvent} from 'view/com/util/images/Image'
|
||||
import {clamp} from 'lib/numbers'
|
||||
|
||||
const MAX_HEIGHT = 300
|
||||
|
||||
interface Dim {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
export const DELAY_PRESS_IN = 500
|
||||
const MIN_ASPECT_RATIO = 0.33 // 1/3
|
||||
const MAX_ASPECT_RATIO = 5 // 5/1
|
||||
|
||||
export function AutoSizedImage({
|
||||
uri,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onPressIn,
|
||||
style,
|
||||
containerStyle,
|
||||
children = null,
|
||||
}: {
|
||||
uri: string
|
||||
onPress?: () => void
|
||||
onLongPress?: () => void
|
||||
style?: StyleProp<ImageStyle>
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
onPressIn?: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const errPal = usePalette('error')
|
||||
const [error, setError] = useState<string | undefined>('')
|
||||
const [imgInfo, setImgInfo] = useState<Dim | undefined>()
|
||||
const [containerInfo, setContainerInfo] = useState<Dim | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
let aborted = false
|
||||
if (!imgInfo) {
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width: number, height: number) => {
|
||||
if (!aborted) {
|
||||
setImgInfo({width, height})
|
||||
}
|
||||
},
|
||||
(err: any) => {
|
||||
if (!aborted) {
|
||||
setError(String(err))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
return () => {
|
||||
aborted = true
|
||||
}
|
||||
}, [uri, imgInfo])
|
||||
|
||||
const onLayout = (evt: LayoutChangeEvent) => {
|
||||
setContainerInfo({
|
||||
width: evt.nativeEvent.layout.width,
|
||||
height: evt.nativeEvent.layout.height,
|
||||
})
|
||||
}
|
||||
|
||||
let calculatedStyle: StyleProp<ImageStyle> | undefined
|
||||
if (imgInfo && containerInfo) {
|
||||
// imgInfo.height / imgInfo.width = x / containerInfo.width
|
||||
// x = imgInfo.height / imgInfo.width * containerInfo.width
|
||||
calculatedStyle = {
|
||||
height: Math.min(
|
||||
MAX_HEIGHT,
|
||||
(imgInfo.height / imgInfo.width) * containerInfo.width,
|
||||
const [aspectRatio, setAspectRatio] = React.useState<number>(1)
|
||||
const onLoad = (e: OnLoadEvent) => {
|
||||
setAspectRatio(
|
||||
clamp(
|
||||
e.nativeEvent.width / e.nativeEvent.height,
|
||||
MIN_ASPECT_RATIO,
|
||||
MAX_ASPECT_RATIO,
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
delayPressIn={DELAY_PRESS_IN}>
|
||||
{error ? (
|
||||
<View style={[styles.errorContainer, errPal.view, containerStyle]}>
|
||||
<Text style={errPal.text}>{error}</Text>
|
||||
</View>
|
||||
) : calculatedStyle ? (
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
<Image style={calculatedStyle} source={{uri}} />
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
style,
|
||||
styles.placeholder,
|
||||
{backgroundColor: theme.palette.default.backgroundLight},
|
||||
]}
|
||||
onLayout={onLayout}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
delayPressIn={DELAY_PRESS_IN}
|
||||
style={[styles.container, style]}>
|
||||
<Image
|
||||
style={[styles.image, {aspectRatio}]}
|
||||
source={{uri}}
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
placeholder: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
},
|
||||
errorContainer: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
container: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
|
|
12
src/view/com/util/images/Image.tsx
Normal file
12
src/view/com/util/images/Image.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import React from 'react'
|
||||
import FastImage, {FastImageProps, Source} from 'react-native-fast-image'
|
||||
export default FastImage
|
||||
export type {OnLoadEvent, ImageStyle, Source} from 'react-native-fast-image'
|
||||
|
||||
export function HighPriorityImage({source, ...props}: FastImageProps) {
|
||||
const updatedSource = {
|
||||
uri: typeof source === 'object' && source ? source.uri : '',
|
||||
priority: FastImage.priority.high,
|
||||
} as Source
|
||||
return <FastImage source={updatedSource} {...props} />
|
||||
}
|
11
src/view/com/util/images/Image.web.tsx
Normal file
11
src/view/com/util/images/Image.web.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import {
|
||||
Image,
|
||||
NativeSyntheticEvent,
|
||||
ImageLoadEventData,
|
||||
ImageSourcePropType,
|
||||
} from 'react-native'
|
||||
export default Image
|
||||
export const HighPriorityImage = Image
|
||||
export type OnLoadEvent = NativeSyntheticEvent<ImageLoadEventData>
|
||||
export type Source = ImageSourcePropType
|
||||
export type {ImageStyle} from 'react-native'
|
|
@ -1,12 +1,12 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
Image,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import Image from 'view/com/util/images/Image'
|
||||
|
||||
export function ImageHorzList({
|
||||
uris,
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
Image,
|
||||
ImageStyle,
|
||||
LayoutChangeEvent,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
|
@ -9,7 +7,9 @@ import {
|
|||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {DELAY_PRESS_IN} from './constants'
|
||||
import Image, {ImageStyle} from 'view/com/util/images/Image'
|
||||
|
||||
export const DELAY_PRESS_IN = 500
|
||||
|
||||
interface Dim {
|
||||
width: number
|
||||
|
@ -23,12 +23,14 @@ export function ImageLayoutGrid({
|
|||
uris,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onPressIn,
|
||||
style,
|
||||
}: {
|
||||
type: ImageLayoutGridType
|
||||
uris: string[]
|
||||
onPress?: (index: number) => void
|
||||
onLongPress?: (index: number) => void
|
||||
onPressIn?: (index: number) => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const [containerInfo, setContainerInfo] = React.useState<Dim | undefined>()
|
||||
|
@ -47,6 +49,7 @@ export function ImageLayoutGrid({
|
|||
type={type}
|
||||
uris={uris}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onLongPress={onLongPress}
|
||||
containerInfo={containerInfo}
|
||||
/>
|
||||
|
@ -60,15 +63,17 @@ function ImageLayoutGridInner({
|
|||
uris,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onPressIn,
|
||||
containerInfo,
|
||||
}: {
|
||||
type: ImageLayoutGridType
|
||||
uris: string[]
|
||||
onPress?: (index: number) => void
|
||||
onLongPress?: (index: number) => void
|
||||
onPressIn?: (index: number) => void
|
||||
containerInfo: Dim
|
||||
}) {
|
||||
const size1 = React.useMemo<ImageStyle>(() => {
|
||||
const size1 = React.useMemo<StyleProp<ImageStyle>>(() => {
|
||||
if (type === 'three') {
|
||||
const size = (containerInfo.width - 10) / 3
|
||||
return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
|
||||
|
@ -77,7 +82,7 @@ function ImageLayoutGridInner({
|
|||
return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
|
||||
}
|
||||
}, [type, containerInfo])
|
||||
const size2 = React.useMemo<ImageStyle>(() => {
|
||||
const size2 = React.useMemo<StyleProp<ImageStyle>>(() => {
|
||||
if (type === 'three') {
|
||||
const size = ((containerInfo.width - 10) / 3) * 2 + 5
|
||||
return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
|
||||
|
@ -93,6 +98,7 @@ function ImageLayoutGridInner({
|
|||
<TouchableOpacity
|
||||
delayPressIn={DELAY_PRESS_IN}
|
||||
onPress={() => onPress?.(0)}
|
||||
onPressIn={() => onPressIn?.(0)}
|
||||
onLongPress={() => onLongPress?.(0)}>
|
||||
<Image source={{uri: uris[0]}} style={size1} />
|
||||
</TouchableOpacity>
|
||||
|
@ -100,6 +106,7 @@ function ImageLayoutGridInner({
|
|||
<TouchableOpacity
|
||||
delayPressIn={DELAY_PRESS_IN}
|
||||
onPress={() => onPress?.(1)}
|
||||
onPressIn={() => onPressIn?.(1)}
|
||||
onLongPress={() => onLongPress?.(1)}>
|
||||
<Image source={{uri: uris[1]}} style={size1} />
|
||||
</TouchableOpacity>
|
||||
|
@ -112,6 +119,7 @@ function ImageLayoutGridInner({
|
|||
<TouchableOpacity
|
||||
delayPressIn={DELAY_PRESS_IN}
|
||||
onPress={() => onPress?.(0)}
|
||||
onPressIn={() => onPressIn?.(0)}
|
||||
onLongPress={() => onLongPress?.(0)}>
|
||||
<Image source={{uri: uris[0]}} style={size2} />
|
||||
</TouchableOpacity>
|
||||
|
@ -120,6 +128,7 @@ function ImageLayoutGridInner({
|
|||
<TouchableOpacity
|
||||
delayPressIn={DELAY_PRESS_IN}
|
||||
onPress={() => onPress?.(1)}
|
||||
onPressIn={() => onPressIn?.(1)}
|
||||
onLongPress={() => onLongPress?.(1)}>
|
||||
<Image source={{uri: uris[1]}} style={size1} />
|
||||
</TouchableOpacity>
|
||||
|
@ -127,6 +136,7 @@ function ImageLayoutGridInner({
|
|||
<TouchableOpacity
|
||||
delayPressIn={DELAY_PRESS_IN}
|
||||
onPress={() => onPress?.(2)}
|
||||
onPressIn={() => onPressIn?.(2)}
|
||||
onLongPress={() => onLongPress?.(2)}>
|
||||
<Image source={{uri: uris[2]}} style={size1} />
|
||||
</TouchableOpacity>
|
||||
|
@ -141,29 +151,33 @@ function ImageLayoutGridInner({
|
|||
<TouchableOpacity
|
||||
delayPressIn={DELAY_PRESS_IN}
|
||||
onPress={() => onPress?.(0)}
|
||||
onPressIn={() => onPressIn?.(0)}
|
||||
onLongPress={() => onLongPress?.(0)}>
|
||||
<Image source={{uri: uris[0]}} style={size1} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.hSpace} />
|
||||
<TouchableOpacity
|
||||
delayPressIn={DELAY_PRESS_IN}
|
||||
onPress={() => onPress?.(1)}
|
||||
onLongPress={() => onLongPress?.(1)}>
|
||||
<Image source={{uri: uris[1]}} style={size1} />
|
||||
onPress={() => onPress?.(2)}
|
||||
onPressIn={() => onPressIn?.(2)}
|
||||
onLongPress={() => onLongPress?.(2)}>
|
||||
<Image source={{uri: uris[2]}} style={size1} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.wSpace} />
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
delayPressIn={DELAY_PRESS_IN}
|
||||
onPress={() => onPress?.(2)}
|
||||
onLongPress={() => onLongPress?.(2)}>
|
||||
<Image source={{uri: uris[2]}} style={size1} />
|
||||
onPress={() => onPress?.(1)}
|
||||
onPressIn={() => onPressIn?.(1)}
|
||||
onLongPress={() => onLongPress?.(1)}>
|
||||
<Image source={{uri: uris[1]}} style={size1} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.hSpace} />
|
||||
<TouchableOpacity
|
||||
delayPressIn={DELAY_PRESS_IN}
|
||||
onPress={() => onPress?.(3)}
|
||||
onPressIn={() => onPressIn?.(3)}
|
||||
onLongPress={() => onLongPress?.(3)}>
|
||||
<Image source={{uri: uris[3]}} style={size1} />
|
||||
</TouchableOpacity>
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
export const DELAY_PRESS_IN = 500
|
|
@ -4,7 +4,7 @@ import {
|
|||
openCropper as openCropperFn,
|
||||
ImageOrVideo,
|
||||
} from 'react-native-image-crop-picker'
|
||||
import {RootStoreModel} from '../../../../../state'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
|
||||
export type {PickedMedia} from './types'
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
/// <reference lib="dom" />
|
||||
|
||||
import {CropImageModal} from '../../../../../state/models/shell-ui'
|
||||
import {CropImageModal} from 'state/models/shell-ui'
|
||||
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
|
||||
export type {PickedMedia} from './types'
|
||||
import {RootStoreModel} from '../../../../../state'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
|
||||
interface PickedFile {
|
||||
uri: string
|
||||
|
@ -31,17 +31,17 @@ export async function openPicker(
|
|||
|
||||
export async function openCamera(
|
||||
_store: RootStoreModel,
|
||||
opts: CameraOpts,
|
||||
_opts: CameraOpts,
|
||||
): Promise<PickedMedia> {
|
||||
const mediaType = opts.mediaType || 'photo'
|
||||
// const mediaType = opts.mediaType || 'photo' TODO
|
||||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
export async function openCropper(
|
||||
_store: RootStoreModel,
|
||||
opts: CropperOpts,
|
||||
_opts: CropperOpts,
|
||||
): Promise<PickedMedia> {
|
||||
const mediaType = opts.mediaType || 'photo'
|
||||
// const mediaType = opts.mediaType || 'photo' TODO
|
||||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
|
|
|
@ -2,29 +2,21 @@ import React from 'react'
|
|||
import {TextStyle, StyleProp} from 'react-native'
|
||||
import {TextLink} from '../Link'
|
||||
import {Text} from './Text'
|
||||
import {lh} from '../../../lib/styles'
|
||||
import {toShortUrl} from '../../../../lib/strings'
|
||||
import {useTheme, TypographyVariant} from '../../../lib/ThemeContext'
|
||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||
|
||||
type TextSlice = {start: number; end: number}
|
||||
type Entity = {
|
||||
index: TextSlice
|
||||
type: string
|
||||
value: string
|
||||
}
|
||||
import {lh} from 'lib/styles'
|
||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||
import {RichText as RichTextObj, Entity} from 'lib/strings/rich-text'
|
||||
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export function RichText({
|
||||
type = 'md',
|
||||
text,
|
||||
entities,
|
||||
richText,
|
||||
lineHeight = 1.2,
|
||||
style,
|
||||
numberOfLines,
|
||||
}: {
|
||||
type?: TypographyVariant
|
||||
text: string
|
||||
entities?: Entity[]
|
||||
richText?: RichTextObj
|
||||
lineHeight?: number
|
||||
style?: StyleProp<TextStyle>
|
||||
numberOfLines?: number
|
||||
|
@ -32,6 +24,12 @@ export function RichText({
|
|||
const theme = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const lineHeightStyle = lh(theme, type, lineHeight)
|
||||
|
||||
if (!richText) {
|
||||
return null
|
||||
}
|
||||
|
||||
const {text, entities} = richText
|
||||
if (!entities?.length) {
|
||||
if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) {
|
||||
style = {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
import {Text as RNText, TextProps} from 'react-native'
|
||||
import {s} from '../../../lib/styles'
|
||||
import {useTheme, TypographyVariant} from '../../../lib/ThemeContext'
|
||||
import {s} from 'lib/styles'
|
||||
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
|
||||
|
||||
export type CustomTextProps = TextProps & {
|
||||
type?: TypographyVariant
|
||||
|
|
|
@ -65,6 +65,8 @@ import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
|
|||
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
|
||||
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
|
||||
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
|
||||
import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
|
||||
import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
|
||||
|
||||
export function setup() {
|
||||
library.add(
|
||||
|
@ -133,5 +135,7 @@ export function setup() {
|
|||
faTrashCan,
|
||||
faX,
|
||||
faXmark,
|
||||
faPlay,
|
||||
faPause,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,93 +0,0 @@
|
|||
import React, {createContext, useContext, useMemo} from 'react'
|
||||
import {TextStyle, useColorScheme, ViewStyle} from 'react-native'
|
||||
import {darkTheme, defaultTheme} from './themes'
|
||||
|
||||
export type ColorScheme = 'light' | 'dark'
|
||||
|
||||
export type PaletteColorName =
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'inverted'
|
||||
| 'error'
|
||||
export type PaletteColor = {
|
||||
background: string
|
||||
backgroundLight: string
|
||||
text: string
|
||||
textLight: string
|
||||
textInverted: string
|
||||
link: string
|
||||
border: string
|
||||
borderDark: string
|
||||
icon: string
|
||||
[k: string]: string
|
||||
}
|
||||
export type Palette = Record<PaletteColorName, PaletteColor>
|
||||
|
||||
export type ShapeName = 'button' | 'bigButton' | 'smallButton'
|
||||
export type Shapes = Record<ShapeName, ViewStyle>
|
||||
|
||||
export type TypographyVariant =
|
||||
| 'xl-thin'
|
||||
| 'xl'
|
||||
| 'xl-medium'
|
||||
| 'xl-bold'
|
||||
| 'xl-heavy'
|
||||
| 'lg-thin'
|
||||
| 'lg'
|
||||
| 'lg-medium'
|
||||
| 'lg-bold'
|
||||
| 'lg-heavy'
|
||||
| 'md-thin'
|
||||
| 'md'
|
||||
| 'md-medium'
|
||||
| 'md-bold'
|
||||
| 'md-heavy'
|
||||
| 'sm-thin'
|
||||
| 'sm'
|
||||
| 'sm-medium'
|
||||
| 'sm-bold'
|
||||
| 'sm-heavy'
|
||||
| 'xs-thin'
|
||||
| 'xs'
|
||||
| 'xs-medium'
|
||||
| 'xs-bold'
|
||||
| 'xs-heavy'
|
||||
| 'title-xl'
|
||||
| 'title-lg'
|
||||
| 'title'
|
||||
| 'title-sm'
|
||||
| 'post-text-lg'
|
||||
| 'post-text'
|
||||
| 'button'
|
||||
| 'mono'
|
||||
export type Typography = Record<TypographyVariant, TextStyle>
|
||||
|
||||
export interface Theme {
|
||||
colorScheme: ColorScheme
|
||||
palette: Palette
|
||||
shapes: Shapes
|
||||
typography: Typography
|
||||
}
|
||||
|
||||
export interface ThemeProviderProps {
|
||||
theme?: ColorScheme
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<Theme>(defaultTheme)
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext)
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
||||
theme,
|
||||
children,
|
||||
}) => {
|
||||
const colorScheme = useColorScheme()
|
||||
|
||||
const value = useMemo(
|
||||
() => ((theme || colorScheme) === 'dark' ? darkTheme : defaultTheme),
|
||||
[colorScheme, theme],
|
||||
)
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
import {StyleProp} from 'react-native'
|
||||
|
||||
export function addStyle<T>(
|
||||
base: StyleProp<T>,
|
||||
addedStyle: StyleProp<T>,
|
||||
): StyleProp<T> {
|
||||
if (Array.isArray(base)) {
|
||||
return base.concat([addedStyle])
|
||||
}
|
||||
return [base, addedStyle]
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import {ImageSourcePropType} from 'react-native'
|
||||
|
||||
export const DEF_AVATAR: ImageSourcePropType = require('../../../public/img/default-avatar.jpg')
|
||||
export const TABS_EXPLAINER: ImageSourcePropType = require('../../../public/img/tabs-explainer.jpg')
|
||||
export const CLOUD_SPLASH: ImageSourcePropType = require('../../../public/img/cloud-splash.png')
|
|
@ -1,7 +0,0 @@
|
|||
import {ImageSourcePropType} from 'react-native'
|
||||
|
||||
export const DEF_AVATAR: ImageSourcePropType = {uri: '/img/default-avatar.jpg'}
|
||||
export const TABS_EXPLAINER: ImageSourcePropType = {
|
||||
uri: '/img/tabs-explainer.jpg',
|
||||
}
|
||||
export const CLOUD_SPLASH: ImageSourcePropType = {uri: '/img/cloud-splash.png'}
|
|
@ -1,12 +0,0 @@
|
|||
import * as React from 'react'
|
||||
import {Animated} from 'react-native'
|
||||
|
||||
export function useAnimatedValue(initialValue: number) {
|
||||
const lazyRef = React.useRef<Animated.Value>()
|
||||
|
||||
if (lazyRef.current === undefined) {
|
||||
lazyRef.current = new Animated.Value(initialValue)
|
||||
}
|
||||
|
||||
return lazyRef.current as Animated.Value
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import {useState} from 'react'
|
||||
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
|
||||
import {RootStoreModel} from '../../../state'
|
||||
|
||||
export type OnScrollCb = (
|
||||
event: NativeSyntheticEvent<NativeScrollEvent>,
|
||||
) => void
|
||||
|
||||
export function useOnMainScroll(store: RootStoreModel) {
|
||||
let [lastY, setLastY] = useState(0)
|
||||
let isMinimal = store.shell.minimalShellMode
|
||||
return function onMainScroll(event: NativeSyntheticEvent<NativeScrollEvent>) {
|
||||
const y = event.nativeEvent.contentOffset.y
|
||||
const dy = y - (lastY || 0)
|
||||
setLastY(y)
|
||||
|
||||
if (!isMinimal && y > 10 && dy > 10) {
|
||||
store.shell.setMinimalShellMode(true)
|
||||
isMinimal = true
|
||||
} else if (isMinimal && (y <= 10 || dy < -10)) {
|
||||
store.shell.setMinimalShellMode(false)
|
||||
isMinimal = false
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
import {TextStyle, ViewStyle} from 'react-native'
|
||||
import {useTheme, PaletteColorName, PaletteColor} from '../ThemeContext'
|
||||
|
||||
export interface UsePaletteValue {
|
||||
colors: PaletteColor
|
||||
view: ViewStyle
|
||||
btn: ViewStyle
|
||||
border: ViewStyle
|
||||
borderDark: ViewStyle
|
||||
text: TextStyle
|
||||
textLight: TextStyle
|
||||
textInverted: TextStyle
|
||||
link: TextStyle
|
||||
icon: TextStyle
|
||||
}
|
||||
export function usePalette(color: PaletteColorName): UsePaletteValue {
|
||||
const palette = useTheme().palette[color]
|
||||
return {
|
||||
colors: palette,
|
||||
view: {
|
||||
backgroundColor: palette.background,
|
||||
},
|
||||
btn: {
|
||||
backgroundColor: palette.backgroundLight,
|
||||
},
|
||||
border: {
|
||||
borderColor: palette.border,
|
||||
},
|
||||
borderDark: {
|
||||
borderColor: palette.borderDark,
|
||||
},
|
||||
text: {
|
||||
color: palette.text,
|
||||
},
|
||||
textLight: {
|
||||
color: palette.textLight,
|
||||
},
|
||||
textInverted: {
|
||||
color: palette.textInverted,
|
||||
},
|
||||
link: {
|
||||
color: palette.link,
|
||||
},
|
||||
icon: {
|
||||
color: palette.icon,
|
||||
},
|
||||
}
|
||||
}
|
|
@ -1,529 +0,0 @@
|
|||
import React from 'react'
|
||||
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
|
||||
import Svg, {Path, Rect} from 'react-native-svg'
|
||||
|
||||
export function GridIcon({
|
||||
style,
|
||||
solid,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
solid?: boolean
|
||||
}) {
|
||||
const DIM = 4
|
||||
const ARC = 2
|
||||
return (
|
||||
<Svg width="24" height="24" style={style}>
|
||||
<Path
|
||||
d={`M4,1 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
|
||||
strokeWidth={2}
|
||||
stroke="#000"
|
||||
fill={solid ? '#000' : undefined}
|
||||
/>
|
||||
<Path
|
||||
d={`M16,1 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
|
||||
strokeWidth={2}
|
||||
stroke="#000"
|
||||
fill={solid ? '#000' : undefined}
|
||||
/>
|
||||
<Path
|
||||
d={`M4,13 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
|
||||
strokeWidth={2}
|
||||
stroke="#000"
|
||||
fill={solid ? '#000' : undefined}
|
||||
/>
|
||||
<Path
|
||||
d={`M16,13 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
|
||||
strokeWidth={2}
|
||||
stroke="#000"
|
||||
fill={solid ? '#000' : undefined}
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
export function GridIconSolid({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
return <GridIcon style={style} solid />
|
||||
}
|
||||
|
||||
export function HomeIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 4,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 48 48"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
style={style}>
|
||||
<Path
|
||||
strokeWidth={strokeWidth}
|
||||
d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeIconSolid({
|
||||
style,
|
||||
size,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 48 48"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
stroke="currentColor"
|
||||
style={style}>
|
||||
<Path
|
||||
strokeWidth={2}
|
||||
fill="currentColor"
|
||||
d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Copyright (c) 2020 Refactoring UI Inc.
|
||||
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
|
||||
export function MagnifyingGlassIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 2,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// https://github.com/Remix-Design/RemixIcon/blob/master/License
|
||||
export function BellIcon({
|
||||
style,
|
||||
size,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path fill="none" d="M0 0h24v24H0z" />
|
||||
<Path
|
||||
fill="currentColor"
|
||||
d="M20 17h2v2H2v-2h2v-7a8 8 0 1 1 16 0v7zm-2 0v-7a6 6 0 1 0-12 0v7h12zm-9 4h6v2H9v-2z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// https://github.com/Remix-Design/RemixIcon/blob/master/License
|
||||
export function BellIconSolid({
|
||||
style,
|
||||
size,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path fill="none" d="M0 0h24v24H0z" />
|
||||
<Path
|
||||
fill="currentColor"
|
||||
d="M 20 17 L 22 17 L 22 19 L 2 19 L 2 17 L 4 17 L 4 10 C 4 3.842 10.667 -0.007 16 3.072 C 18.475 4.501 20 7.142 20 10 L 20 17 Z M 9 21 L 15 21 L 15 23 L 9 23 L 9 21 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CogIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || 32}
|
||||
height={size || 32}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Copyright (c) 2020 Refactoring UI Inc.
|
||||
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
|
||||
export function UserIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || 32}
|
||||
height={size || 32}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Copyright (c) 2020 Refactoring UI Inc.
|
||||
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
|
||||
export function UserGroupIcon({
|
||||
style,
|
||||
size,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || 32}
|
||||
height={size || 32}
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function RepostIcon({
|
||||
style,
|
||||
size = 24,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth: number
|
||||
}) {
|
||||
return (
|
||||
<Svg viewBox="0 0 24 24" width={size} height={size} style={style}>
|
||||
<Path
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
d="M 14.437 17.081 L 5.475 17.095 C 4.7 17.095 4.072 16.467 4.072 15.692 L 4.082 5.65 L 1.22 9.854 M 4.082 5.65 L 7.006 9.854 M 9.859 5.65 L 18.625 5.654 C 19.4 5.654 20.028 6.282 20.028 7.057 L 20.031 17.081 L 17.167 12.646 M 20.031 17.081 L 22.866 12.646"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc.
|
||||
export function HeartIcon({
|
||||
style,
|
||||
size = 24,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth: number
|
||||
}) {
|
||||
return (
|
||||
<Svg viewBox="0 0 24 24" width={size} height={size} style={style}>
|
||||
<Path
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
d="M 3.859 13.537 L 10.918 20.127 C 11.211 20.4 11.598 20.552 12 20.552 C 12.402 20.552 12.789 20.4 13.082 20.127 L 20.141 13.537 C 21.328 12.431 22 10.88 22 9.259 L 22 9.033 C 22 6.302 20.027 3.974 17.336 3.525 C 15.555 3.228 13.742 3.81 12.469 5.084 L 12 5.552 L 11.531 5.084 C 10.258 3.81 8.445 3.228 6.664 3.525 C 3.973 3.974 2 6.302 2 9.033 L 2 9.259 C 2 10.88 2.672 12.431 3.859 13.537 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc.
|
||||
export function HeartIconSolid({
|
||||
style,
|
||||
size = 24,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
<Svg viewBox="0 0 24 24" width={size} height={size} style={style}>
|
||||
<Path
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1}
|
||||
d="M 3.859 13.537 L 10.918 20.127 C 11.211 20.4 11.598 20.552 12 20.552 C 12.402 20.552 12.789 20.4 13.082 20.127 L 20.141 13.537 C 21.328 12.431 22 10.88 22 9.259 L 22 9.033 C 22 6.302 20.027 3.974 17.336 3.525 C 15.555 3.228 13.742 3.81 12.469 5.084 L 12 5.552 L 11.531 5.084 C 10.258 3.81 8.445 3.228 6.664 3.525 C 3.973 3.974 2 6.302 2 9.033 L 2 9.259 C 2 10.88 2.672 12.431 3.859 13.537 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function UpIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.3,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 14 14"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M 7 3 L 2 8 L 4.5 8 L 4.5 11.5 L 9.5 11.5 L 9.5 8 L 12 8 L 7 3 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function UpIconSolid({
|
||||
style,
|
||||
size,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 14 14"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeWidth={1.3}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M 7 3 L 2 8 L 4.5 8 L 4.5 11.5 L 9.5 11.5 L 9.5 8 L 12 8 L 7 3 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DownIcon({
|
||||
style,
|
||||
size,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 14 14"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeWidth={1.3}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M 7 11.5 L 2 6.5 L 4.5 6.5 L 4.5 3 L 9.5 3 L 9.5 6.5 L 12 6.5 L 7 11.5 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DownIconSolid({
|
||||
style,
|
||||
size,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 14 14"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeWidth={1.3}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M 7 11.5 L 2 6.5 L 4.5 6.5 L 4.5 3 L 9.5 3 L 9.5 6.5 L 12 6.5 L 7 11.5 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Copyright (c) 2020 Refactoring UI Inc.
|
||||
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
|
||||
export function CommentBottomArrow({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.3,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
let color = 'currentColor'
|
||||
if (
|
||||
style &&
|
||||
typeof style === 'object' &&
|
||||
'color' in style &&
|
||||
typeof style.color === 'string'
|
||||
) {
|
||||
color = style.color
|
||||
}
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={strokeWidth || 2.5}
|
||||
stroke={color}
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.068.157 2.148.279 3.238.364.466.037.893.281 1.153.671L12 21l2.652-3.978c.26-.39.687-.634 1.153-.67 1.09-.086 2.17-.208 3.238-.365 1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SquareIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.3,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={strokeWidth || 1}
|
||||
stroke="currentColor"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Rect x="6" y="6" width="12" height="12" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function RectWideIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.3,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={strokeWidth || 1}
|
||||
stroke="currentColor"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Rect x="4" y="6" width="16" height="12" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function RectTallIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.3,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={strokeWidth || 1}
|
||||
stroke="currentColor"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Rect x="6" y="4" width="12" height="16" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue