Merge remote-tracking branch 'origin/main' into mod-auth
* origin/main: Set show_follow_suggestions_in_profile to true (#5205) Set fixed_bottom_bar to true (#5203) [Statsig] Add more events to downsample, increase downsample rate (#5198) fix: remove duplicate style `rounded_sm` (#5201) Add cursor pointer to 'New post' button (#5109) Add emoji picker to chat composer (#5196) [Video] Handle push/pop on Android for autoplay (#5194) nvm fix action Trigger a build maybe Fix starter packs scroll (#5190) Redesign play button (#5192) cleanup quick integration of ipcc service
This commit is contained in:
@@ -60,6 +60,12 @@ func run(args []string) {
|
|||||||
Value: "",
|
Value: "",
|
||||||
EnvVars: []string{"LINK_HOST"},
|
EnvVars: []string{"LINK_HOST"},
|
||||||
},
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "ipcc-host",
|
||||||
|
Usage: "scheme, hostname, and port of ipcc service",
|
||||||
|
Value: "https://localhost:8730",
|
||||||
|
EnvVars: []string{"IPCC_HOST"},
|
||||||
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "debug",
|
Name: "debug",
|
||||||
Usage: "Enable debug mode",
|
Usage: "Enable debug mode",
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -41,6 +46,7 @@ type Config struct {
|
|||||||
appviewHost string
|
appviewHost string
|
||||||
ogcardHost string
|
ogcardHost string
|
||||||
linkHost string
|
linkHost string
|
||||||
|
ipccHost string
|
||||||
}
|
}
|
||||||
|
|
||||||
func serve(cctx *cli.Context) error {
|
func serve(cctx *cli.Context) error {
|
||||||
@@ -49,6 +55,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
appviewHost := cctx.String("appview-host")
|
appviewHost := cctx.String("appview-host")
|
||||||
ogcardHost := cctx.String("ogcard-host")
|
ogcardHost := cctx.String("ogcard-host")
|
||||||
linkHost := cctx.String("link-host")
|
linkHost := cctx.String("link-host")
|
||||||
|
ipccHost := cctx.String("ipcc-host")
|
||||||
basicAuthPassword := cctx.String("basic-auth-password")
|
basicAuthPassword := cctx.String("basic-auth-password")
|
||||||
|
|
||||||
// Echo
|
// Echo
|
||||||
@@ -91,6 +98,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
appviewHost: appviewHost,
|
appviewHost: appviewHost,
|
||||||
ogcardHost: ogcardHost,
|
ogcardHost: ogcardHost,
|
||||||
linkHost: linkHost,
|
linkHost: linkHost,
|
||||||
|
ipccHost: ipccHost,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +269,9 @@ func serve(cctx *cli.Context) error {
|
|||||||
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
|
|
||||||
|
// ipcc
|
||||||
|
e.GET("/ipcc", server.WebIpCC)
|
||||||
|
|
||||||
if linkHost != "" {
|
if linkHost != "" {
|
||||||
linkUrl, err := url.Parse(linkHost)
|
linkUrl, err := url.Parse(linkHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -520,3 +531,61 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
|||||||
data["requestHost"] = req.Host
|
data["requestHost"] = req.Host
|
||||||
return c.Render(http.StatusOK, "profile.html", data)
|
return c.Render(http.StatusOK, "profile.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IPCCRequest struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
type IPCCResponse struct {
|
||||||
|
CC string `json:"countryCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) WebIpCC(c echo.Context) error {
|
||||||
|
realIP := c.RealIP()
|
||||||
|
addr, err := netip.ParseAddr(realIP)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("could not parse IP %q %s", realIP, err)
|
||||||
|
return c.JSON(400, IPCCResponse{})
|
||||||
|
}
|
||||||
|
var request []byte
|
||||||
|
if addr.Is4() {
|
||||||
|
ip4 := addr.As4()
|
||||||
|
var dest [8]byte
|
||||||
|
base64.StdEncoding.Encode(dest[:], ip4[:])
|
||||||
|
request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
|
||||||
|
} else if addr.Is6() {
|
||||||
|
ip6 := addr.As16()
|
||||||
|
var dest [24]byte
|
||||||
|
base64.StdEncoding.Encode(dest[:], ip6[:])
|
||||||
|
request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
|
||||||
|
}
|
||||||
|
|
||||||
|
ipccUrlBuilder, err := url.Parse(srv.cfg.ipccHost)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("ipcc misconfigured bad url %s", err)
|
||||||
|
return c.JSON(500, IPCCResponse{})
|
||||||
|
}
|
||||||
|
ipccUrlBuilder.Path = "ipccdata.IpCcService/Lookup"
|
||||||
|
ipccUrl := ipccUrlBuilder.String()
|
||||||
|
cl := http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
postBodyReader := bytes.NewReader(request)
|
||||||
|
response, err := cl.Post(ipccUrl, "application/json", postBodyReader)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("ipcc backend error %s", err)
|
||||||
|
return c.JSON(500, IPCCResponse{})
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
dec := json.NewDecoder(response.Body)
|
||||||
|
var outResponse IPCCResponse
|
||||||
|
err = dec.Decode(&outResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("ipcc bad response %s", err)
|
||||||
|
return c.JSON(500, IPCCResponse{})
|
||||||
|
}
|
||||||
|
return c.JSON(200, outResponse)
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -139,7 +139,7 @@
|
|||||||
"expo-system-ui": "~3.0.4",
|
"expo-system-ui": "~3.0.4",
|
||||||
"expo-task-manager": "~11.8.1",
|
"expo-task-manager": "~11.8.1",
|
||||||
"expo-updates": "~0.25.14",
|
"expo-updates": "~0.25.14",
|
||||||
"expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz",
|
"expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"fast-text-encoding": "^1.0.6",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
|||||||
+4
-5
@@ -661,16 +661,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
|||||||
linking={LINKING}
|
linking={LINKING}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onStateChange={() => {
|
onStateChange={() => {
|
||||||
logEvent('router:navigate:sampled', {
|
const routeName = getCurrentRouteName()
|
||||||
from: prevLoggedRouteName.current,
|
if (routeName === 'Notifications') {
|
||||||
})
|
logEvent('router:navigate:notifications:sampled', {})
|
||||||
prevLoggedRouteName.current = getCurrentRouteName()
|
}
|
||||||
}}
|
}}
|
||||||
onReady={() => {
|
onReady={() => {
|
||||||
attachRouteToLogEvents(getCurrentRouteName)
|
attachRouteToLogEvents(getCurrentRouteName)
|
||||||
logModuleInitTime()
|
logModuleInitTime()
|
||||||
onReady()
|
onReady()
|
||||||
logEvent('router:navigate:sampled', {})
|
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {useNavigation} from '@react-navigation/native'
|
|||||||
|
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
@@ -177,14 +176,9 @@ function useExperimentalSuggestedUsersQuery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
||||||
const gate = useGate()
|
|
||||||
const [feedType, feedUri] = feed.split('|')
|
const [feedType, feedUri] = feed.split('|')
|
||||||
if (feedType === 'author') {
|
if (feedType === 'author') {
|
||||||
if (gate('show_follow_suggestions_in_profile')) {
|
return <SuggestedFollowsProfile did={feedUri} />
|
||||||
return <SuggestedFollowsProfile did={feedUri} />
|
|
||||||
} else {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return <SuggestedFollowsHome />
|
return <SuggestedFollowsHome />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,8 +276,8 @@ export function DescriptionPlaceholder() {
|
|||||||
export type FollowButtonProps = {
|
export type FollowButtonProps = {
|
||||||
profile: AppBskyActorDefs.ProfileViewBasic
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
moderationOpts: ModerationOpts
|
moderationOpts: ModerationOpts
|
||||||
logContext: LogEvents['profile:follow']['logContext'] &
|
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||||
LogEvents['profile:unfollow']['logContext']
|
LogEvents['profile:unfollow:sampled']['logContext']
|
||||||
} & Partial<ButtonProps>
|
} & Partial<ButtonProps>
|
||||||
|
|
||||||
export function FollowButton(props: FollowButtonProps) {
|
export function FollowButton(props: FollowButtonProps) {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
|||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const bottomBarOffset = useBottomBarOffset(200)
|
const bottomBarOffset = useBottomBarOffset(300)
|
||||||
const initialNumToRender = useInitialNumToRender()
|
const initialNumToRender = useInitialNumToRender()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {data, refetch, isError} = useAllListMembersQuery(listUri)
|
const {data, refetch, isError} = useAllListMembersQuery(listUri)
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ export function useFollowMethods({
|
|||||||
logContext,
|
logContext,
|
||||||
}: {
|
}: {
|
||||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||||
logContext: LogEvents['profile:follow']['logContext'] &
|
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||||
LogEvents['profile:unfollow']['logContext']
|
LogEvents['profile:unfollow:sampled']['logContext']
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const requireAuth = useRequireAuth()
|
const requireAuth = useRequireAuth()
|
||||||
|
|||||||
@@ -2,24 +2,47 @@ import React from 'react'
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||||
|
|
||||||
export function PlayButtonIcon({size = 44}: {size?: number}) {
|
export function PlayButtonIcon({size = 36}: {size?: number}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975
|
||||||
|
const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
|
a.overflow_hidden,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.justify_center,
|
a.justify_center,
|
||||||
|
t.atoms.shadow_lg,
|
||||||
{
|
{
|
||||||
backgroundColor: t.palette.primary_500,
|
width: size + size / 1.5,
|
||||||
width: size + 16,
|
height: size + size / 1.5,
|
||||||
height: size + 16,
|
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<PlayIcon height={size} width={size} style={{color: 'white'}} />
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
{
|
||||||
|
backgroundColor: bg,
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<PlayIcon
|
||||||
|
width={size}
|
||||||
|
fill={fg}
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
a.z_10,
|
||||||
|
{
|
||||||
|
left: size / 50,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export type LogEvents = {
|
|||||||
secondsActive: number
|
secondsActive: number
|
||||||
}
|
}
|
||||||
'state:foreground:sampled': {}
|
'state:foreground:sampled': {}
|
||||||
'router:navigate:sampled': {}
|
'router:navigate:notifications:sampled': {}
|
||||||
'deepLink:referrerReceived': {
|
'deepLink:referrerReceived': {
|
||||||
to: string
|
to: string
|
||||||
referrer: string
|
referrer: string
|
||||||
@@ -127,25 +127,25 @@ export type LogEvents = {
|
|||||||
langs: string
|
langs: string
|
||||||
logContext: 'Composer'
|
logContext: 'Composer'
|
||||||
}
|
}
|
||||||
'post:like': {
|
'post:like:sampled': {
|
||||||
doesLikerFollowPoster: boolean | undefined
|
doesLikerFollowPoster: boolean | undefined
|
||||||
doesPosterFollowLiker: boolean | undefined
|
doesPosterFollowLiker: boolean | undefined
|
||||||
likerClout: number | undefined
|
likerClout: number | undefined
|
||||||
postClout: number | undefined
|
postClout: number | undefined
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
}
|
}
|
||||||
'post:repost': {
|
'post:repost:sampled': {
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
}
|
}
|
||||||
'post:unlike': {
|
'post:unlike:sampled': {
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
}
|
}
|
||||||
'post:unrepost': {
|
'post:unrepost:sampled': {
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
}
|
}
|
||||||
'post:mute': {}
|
'post:mute': {}
|
||||||
'post:unmute': {}
|
'post:unmute': {}
|
||||||
'profile:follow': {
|
'profile:follow:sampled': {
|
||||||
didBecomeMutual: boolean | undefined
|
didBecomeMutual: boolean | undefined
|
||||||
followeeClout: number | undefined
|
followeeClout: number | undefined
|
||||||
followerClout: number | undefined
|
followerClout: number | undefined
|
||||||
@@ -162,7 +162,7 @@ export type LogEvents = {
|
|||||||
| 'FeedInterstitial'
|
| 'FeedInterstitial'
|
||||||
| 'ProfileHeaderSuggestedFollows'
|
| 'ProfileHeaderSuggestedFollows'
|
||||||
}
|
}
|
||||||
'profile:unfollow': {
|
'profile:unfollow:sampled': {
|
||||||
logContext:
|
logContext:
|
||||||
| 'RecommendedFollowsItem'
|
| 'RecommendedFollowsItem'
|
||||||
| 'PostThreadItem'
|
| 'PostThreadItem'
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
export type Gate =
|
export type Gate =
|
||||||
// Keep this alphabetic please.
|
// Keep this alphabetic please.
|
||||||
| 'debug_show_feedcontext'
|
| 'debug_show_feedcontext'
|
||||||
| 'fixed_bottom_bar'
|
|
||||||
| 'onboarding_minimum_interests'
|
| 'onboarding_minimum_interests'
|
||||||
| 'suggested_feeds_interstitial'
|
| 'suggested_feeds_interstitial'
|
||||||
| 'show_follow_suggestions_in_profile'
|
|
||||||
| 'video_debug' // not recommended
|
| 'video_debug' // not recommended
|
||||||
| 'video_upload' // upload videos
|
| 'video_upload' // upload videos
|
||||||
| 'video_view_on_posts' // see posted videos
|
| 'video_view_on_posts' // see posted videos
|
||||||
|
|||||||
@@ -89,8 +89,9 @@ export function toClout(n: number | null | undefined): number | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DOWNSAMPLE_RATE = 0.95 // 95% likely
|
||||||
const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
|
const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
|
||||||
'router:navigate:sampled',
|
'router:navigate:notifications:sampled',
|
||||||
'state:background:sampled',
|
'state:background:sampled',
|
||||||
'state:foreground:sampled',
|
'state:foreground:sampled',
|
||||||
'home:feedDisplayed:sampled',
|
'home:feedDisplayed:sampled',
|
||||||
@@ -99,8 +100,14 @@ const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
|
|||||||
'discover:clickthrough:sampled',
|
'discover:clickthrough:sampled',
|
||||||
'discover:engaged:sampled',
|
'discover:engaged:sampled',
|
||||||
'discover:seen:sampled',
|
'discover:seen:sampled',
|
||||||
|
'post:like:sampled',
|
||||||
|
'post:unlike:sampled',
|
||||||
|
'post:repost:sampled',
|
||||||
|
'post:unrepost:sampled',
|
||||||
|
'profile:follow:sampled',
|
||||||
|
'profile:unfollow:sampled',
|
||||||
])
|
])
|
||||||
const isDownsampledSession = Math.random() < 0.9 // 90% likely
|
const isDownsampledSession = Math.random() < DOWNSAMPLE_RATE
|
||||||
|
|
||||||
export function logEvent<E extends keyof LogEvents>(
|
export function logEvent<E extends keyof LogEvents>(
|
||||||
eventName: E & string,
|
eventName: E & string,
|
||||||
@@ -117,12 +124,16 @@ export function logEvent<E extends keyof LogEvents>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDownsampledSession && DOWNSAMPLED_EVENTS.has(eventName)) {
|
const isDownsampledEvent = DOWNSAMPLED_EVENTS.has(eventName)
|
||||||
|
if (isDownsampledSession && isDownsampledEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const fullMetadata = {
|
const fullMetadata = {
|
||||||
...rawMetadata,
|
...rawMetadata,
|
||||||
} as Record<string, string> // Statsig typings are unnecessarily strict here.
|
} as Record<string, string> // Statsig typings are unnecessarily strict here.
|
||||||
|
if (isDownsampledEvent) {
|
||||||
|
fullMetadata.downsampleRate = DOWNSAMPLE_RATE.toString()
|
||||||
|
}
|
||||||
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
|
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
|
||||||
if (Statsig.initializeCalled()) {
|
if (Statsig.initializeCalled()) {
|
||||||
Statsig.logEvent(eventName, null, fullMetadata)
|
Statsig.logEvent(eventName, null, fullMetadata)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
useSaveMessageDraft,
|
useSaveMessageDraft,
|
||||||
} from '#/state/messages/message-drafts'
|
} from '#/state/messages/message-drafts'
|
||||||
import {isIOS} from 'platform/detection'
|
import {isIOS} from 'platform/detection'
|
||||||
|
import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||||
@@ -41,6 +42,7 @@ export function MessageInput({
|
|||||||
hasEmbed: boolean
|
hasEmbed: boolean
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
openEmojiPicker?: (pos: EmojiPickerPosition) => void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ import {
|
|||||||
} from '#/state/messages/message-drafts'
|
} from '#/state/messages/message-drafts'
|
||||||
import {isSafari, isTouchDevice} from 'lib/browser'
|
import {isSafari, isTouchDevice} from 'lib/browser'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||||
|
import {
|
||||||
|
Emoji,
|
||||||
|
EmojiPickerPosition,
|
||||||
|
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||||
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||||
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
||||||
|
|
||||||
@@ -23,11 +30,13 @@ export function MessageInput({
|
|||||||
hasEmbed,
|
hasEmbed,
|
||||||
setEmbed,
|
setEmbed,
|
||||||
children,
|
children,
|
||||||
|
openEmojiPicker,
|
||||||
}: {
|
}: {
|
||||||
onSendMessage: (message: string) => void
|
onSendMessage: (message: string) => void
|
||||||
hasEmbed: boolean
|
hasEmbed: boolean
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
openEmojiPicker?: (pos: EmojiPickerPosition) => void
|
||||||
}) {
|
}) {
|
||||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -40,6 +49,7 @@ export function MessageInput({
|
|||||||
const [isFocused, setIsFocused] = React.useState(false)
|
const [isFocused, setIsFocused] = React.useState(false)
|
||||||
const [isHovered, setIsHovered] = React.useState(false)
|
const [isHovered, setIsHovered] = React.useState(false)
|
||||||
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
|
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
|
||||||
|
const textAreaRef = React.useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
const onSubmit = React.useCallback(() => {
|
const onSubmit = React.useCallback(() => {
|
||||||
if (!hasEmbed && message.trim() === '') {
|
if (!hasEmbed && message.trim() === '') {
|
||||||
@@ -94,6 +104,23 @@ export function MessageInput({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onEmojiInserted = React.useCallback(
|
||||||
|
(emoji: Emoji) => {
|
||||||
|
const position = textAreaRef.current?.selectionStart ?? 0
|
||||||
|
setMessage(
|
||||||
|
message =>
|
||||||
|
message.slice(0, position) + emoji.native + message.slice(position),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[setMessage],
|
||||||
|
)
|
||||||
|
React.useEffect(() => {
|
||||||
|
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
|
||||||
|
return () => {
|
||||||
|
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
|
||||||
|
}
|
||||||
|
}, [onEmojiInserted])
|
||||||
|
|
||||||
useSaveMessageDraft(message)
|
useSaveMessageDraft(message)
|
||||||
useExtractEmbedFromFacets(message, setEmbed)
|
useExtractEmbedFromFacets(message, setEmbed)
|
||||||
|
|
||||||
@@ -106,7 +133,7 @@ export function MessageInput({
|
|||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
{
|
{
|
||||||
paddingRight: a.p_sm.padding - 2,
|
paddingRight: a.p_sm.padding - 2,
|
||||||
paddingLeft: a.p_md.padding - 2,
|
paddingLeft: a.p_sm.padding - 2,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: 23,
|
borderRadius: 23,
|
||||||
borderColor: 'transparent',
|
borderColor: 'transparent',
|
||||||
@@ -118,7 +145,44 @@ export function MessageInput({
|
|||||||
// @ts-expect-error web only
|
// @ts-expect-error web only
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}>
|
onMouseLeave={() => setIsHovered(false)}>
|
||||||
|
<Button
|
||||||
|
onPress={e => {
|
||||||
|
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
|
||||||
|
openEmojiPicker?.({top: py, left: px, right: px, bottom: py})
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.overflow_hidden,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
{
|
||||||
|
marginTop: 5,
|
||||||
|
height: 30,
|
||||||
|
width: 30,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
label={_(msg`Open emoji picker`)}>
|
||||||
|
{state => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
state.hovered || state.focused || state.pressed
|
||||||
|
? t.atoms.bg.backgroundColor
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<EmojiSmile size="lg" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
|
ref={textAreaRef}
|
||||||
style={StyleSheet.flatten([
|
style={StyleSheet.flatten([
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.px_sm,
|
a.px_sm,
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ import {useAgent} from '#/state/session'
|
|||||||
import {clamp} from 'lib/numbers'
|
import {clamp} from 'lib/numbers'
|
||||||
import {ScrollProvider} from 'lib/ScrollContext'
|
import {ScrollProvider} from 'lib/ScrollContext'
|
||||||
import {isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {
|
||||||
|
EmojiPicker,
|
||||||
|
EmojiPickerState,
|
||||||
|
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||||
import {List} from 'view/com/util/List'
|
import {List} from 'view/com/util/List'
|
||||||
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
|
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
|
||||||
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
|
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
|
||||||
@@ -97,6 +101,12 @@ export function MessagesList({
|
|||||||
startContentOffset: 0,
|
startContentOffset: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [emojiPickerState, setEmojiPickerState] =
|
||||||
|
React.useState<EmojiPickerState>({
|
||||||
|
isOpen: false,
|
||||||
|
pos: {top: 0, left: 0, right: 0, bottom: 0},
|
||||||
|
})
|
||||||
|
|
||||||
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
||||||
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
|
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
|
||||||
// the bottom.
|
// the bottom.
|
||||||
@@ -422,13 +432,22 @@ export function MessagesList({
|
|||||||
<MessageInput
|
<MessageInput
|
||||||
onSendMessage={onSendMessage}
|
onSendMessage={onSendMessage}
|
||||||
hasEmbed={!!embedUri}
|
hasEmbed={!!embedUri}
|
||||||
setEmbed={setEmbed}>
|
setEmbed={setEmbed}
|
||||||
|
openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}>
|
||||||
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||||
</MessageInput>
|
</MessageInput>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</KeyboardStickyView>
|
</KeyboardStickyView>
|
||||||
|
|
||||||
|
{isWeb && (
|
||||||
|
<EmojiPicker
|
||||||
|
pinToTop
|
||||||
|
state={emojiPickerState}
|
||||||
|
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
|
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import {
|
|||||||
ModerationOpts,
|
ModerationOpts,
|
||||||
RichText as RichTextAPI,
|
RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {Shadow} from '#/state/cache/types'
|
||||||
@@ -23,10 +21,9 @@ import {useRequireAuth, useSession} from '#/state/session'
|
|||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||||
import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
|
|
||||||
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
|
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
|
||||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
@@ -59,8 +56,6 @@ let ProfileHeaderStandard = ({
|
|||||||
}: Props): React.ReactNode => {
|
}: Props): React.ReactNode => {
|
||||||
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
||||||
useProfileShadow(profileUnshadowed)
|
useProfileShadow(profileUnshadowed)
|
||||||
const t = useTheme()
|
|
||||||
const gate = useGate()
|
|
||||||
const {currentAccount, hasSession} = useSession()
|
const {currentAccount, hasSession} = useSession()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {openModal} = useModalControls()
|
const {openModal} = useModalControls()
|
||||||
@@ -69,7 +64,6 @@ let ProfileHeaderStandard = ({
|
|||||||
() => moderateProfile(profile, moderationOpts),
|
() => moderateProfile(profile, moderationOpts),
|
||||||
[profile, moderationOpts],
|
[profile, moderationOpts],
|
||||||
)
|
)
|
||||||
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
|
|
||||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||||
profile,
|
profile,
|
||||||
'ProfileHeader',
|
'ProfileHeader',
|
||||||
@@ -202,34 +196,7 @@ let ProfileHeaderStandard = ({
|
|||||||
)
|
)
|
||||||
) : !profile.viewer?.blockedBy ? (
|
) : !profile.viewer?.blockedBy ? (
|
||||||
<>
|
<>
|
||||||
{hasSession && (
|
{hasSession && <MessageProfileButton profile={profile} />}
|
||||||
<>
|
|
||||||
<MessageProfileButton profile={profile} />
|
|
||||||
{!gate('show_follow_suggestions_in_profile') && (
|
|
||||||
<Button
|
|
||||||
testID="suggestedFollowsBtn"
|
|
||||||
size="small"
|
|
||||||
color={showSuggestedFollows ? 'primary' : 'secondary'}
|
|
||||||
variant="solid"
|
|
||||||
shape="round"
|
|
||||||
onPress={() =>
|
|
||||||
setShowSuggestedFollows(!showSuggestedFollows)
|
|
||||||
}
|
|
||||||
label={_(msg`Show follows similar to ${profile.handle}`)}
|
|
||||||
style={{width: 36, height: 36}}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="user-plus"
|
|
||||||
style={
|
|
||||||
showSuggestedFollows
|
|
||||||
? {color: t.palette.white}
|
|
||||||
: t.atoms.text
|
|
||||||
}
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
|
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
|
||||||
@@ -294,19 +261,6 @@ let ProfileHeaderStandard = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{showSuggestedFollows && (
|
|
||||||
<ProfileHeaderSuggestedFollows
|
|
||||||
actorDid={profile.did}
|
|
||||||
requestDismiss={() => {
|
|
||||||
if (showSuggestedFollows) {
|
|
||||||
setShowSuggestedFollows(false)
|
|
||||||
} else {
|
|
||||||
track('ProfileHeader:SuggestedFollowsOpened')
|
|
||||||
setShowSuggestedFollows(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Prompt.Basic
|
<Prompt.Basic
|
||||||
control={unblockPromptControl}
|
control={unblockPromptControl}
|
||||||
title={_(msg`Unblock Account?`)}
|
title={_(msg`Unblock Account?`)}
|
||||||
|
|||||||
+12
-12
@@ -99,8 +99,8 @@ export function useGetPosts() {
|
|||||||
|
|
||||||
export function usePostLikeMutationQueue(
|
export function usePostLikeMutationQueue(
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||||
logContext: LogEvents['post:like']['logContext'] &
|
logContext: LogEvents['post:like:sampled']['logContext'] &
|
||||||
LogEvents['post:unlike']['logContext'],
|
LogEvents['post:unlike:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const postUri = post.uri
|
const postUri = post.uri
|
||||||
@@ -158,7 +158,7 @@ export function usePostLikeMutationQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usePostLikeMutation(
|
function usePostLikeMutation(
|
||||||
logContext: LogEvents['post:like']['logContext'],
|
logContext: LogEvents['post:like:sampled']['logContext'],
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||||
) {
|
) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -175,7 +175,7 @@ function usePostLikeMutation(
|
|||||||
if (currentAccount) {
|
if (currentAccount) {
|
||||||
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
||||||
}
|
}
|
||||||
logEvent('post:like', {
|
logEvent('post:like:sampled', {
|
||||||
logContext,
|
logContext,
|
||||||
doesPosterFollowLiker: postAuthor.viewer
|
doesPosterFollowLiker: postAuthor.viewer
|
||||||
? Boolean(postAuthor.viewer.followedBy)
|
? Boolean(postAuthor.viewer.followedBy)
|
||||||
@@ -200,12 +200,12 @@ function usePostLikeMutation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usePostUnlikeMutation(
|
function usePostUnlikeMutation(
|
||||||
logContext: LogEvents['post:unlike']['logContext'],
|
logContext: LogEvents['post:unlike:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useMutation<void, Error, {postUri: string; likeUri: string}>({
|
return useMutation<void, Error, {postUri: string; likeUri: string}>({
|
||||||
mutationFn: ({likeUri}) => {
|
mutationFn: ({likeUri}) => {
|
||||||
logEvent('post:unlike', {logContext})
|
logEvent('post:unlike:sampled', {logContext})
|
||||||
return agent.deleteLike(likeUri)
|
return agent.deleteLike(likeUri)
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
@@ -216,8 +216,8 @@ function usePostUnlikeMutation(
|
|||||||
|
|
||||||
export function usePostRepostMutationQueue(
|
export function usePostRepostMutationQueue(
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||||
logContext: LogEvents['post:repost']['logContext'] &
|
logContext: LogEvents['post:repost:sampled']['logContext'] &
|
||||||
LogEvents['post:unrepost']['logContext'],
|
LogEvents['post:unrepost:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const postUri = post.uri
|
const postUri = post.uri
|
||||||
@@ -273,7 +273,7 @@ export function usePostRepostMutationQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usePostRepostMutation(
|
function usePostRepostMutation(
|
||||||
logContext: LogEvents['post:repost']['logContext'],
|
logContext: LogEvents['post:repost:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useMutation<
|
return useMutation<
|
||||||
@@ -282,7 +282,7 @@ function usePostRepostMutation(
|
|||||||
{uri: string; cid: string} // the post's uri and cid
|
{uri: string; cid: string} // the post's uri and cid
|
||||||
>({
|
>({
|
||||||
mutationFn: post => {
|
mutationFn: post => {
|
||||||
logEvent('post:repost', {logContext})
|
logEvent('post:repost:sampled', {logContext})
|
||||||
return agent.repost(post.uri, post.cid)
|
return agent.repost(post.uri, post.cid)
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
@@ -292,12 +292,12 @@ function usePostRepostMutation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usePostUnrepostMutation(
|
function usePostUnrepostMutation(
|
||||||
logContext: LogEvents['post:unrepost']['logContext'],
|
logContext: LogEvents['post:unrepost:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useMutation<void, Error, {postUri: string; repostUri: string}>({
|
return useMutation<void, Error, {postUri: string; repostUri: string}>({
|
||||||
mutationFn: ({repostUri}) => {
|
mutationFn: ({repostUri}) => {
|
||||||
logEvent('post:unrepost', {logContext})
|
logEvent('post:unrepost:sampled', {logContext})
|
||||||
return agent.deleteRepost(repostUri)
|
return agent.deleteRepost(repostUri)
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
|
|||||||
@@ -219,8 +219,8 @@ export function useProfileUpdateMutation() {
|
|||||||
|
|
||||||
export function useProfileFollowMutationQueue(
|
export function useProfileFollowMutationQueue(
|
||||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
||||||
logContext: LogEvents['profile:follow']['logContext'] &
|
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||||
LogEvents['profile:unfollow']['logContext'],
|
LogEvents['profile:follow:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -291,7 +291,7 @@ export function useProfileFollowMutationQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useProfileFollowMutation(
|
function useProfileFollowMutation(
|
||||||
logContext: LogEvents['profile:follow']['logContext'],
|
logContext: LogEvents['profile:follow:sampled']['logContext'],
|
||||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
||||||
) {
|
) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -306,7 +306,7 @@ function useProfileFollowMutation(
|
|||||||
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
||||||
}
|
}
|
||||||
captureAction(ProgressGuideAction.Follow)
|
captureAction(ProgressGuideAction.Follow)
|
||||||
logEvent('profile:follow', {
|
logEvent('profile:follow:sampled', {
|
||||||
logContext,
|
logContext,
|
||||||
didBecomeMutual: profile.viewer
|
didBecomeMutual: profile.viewer
|
||||||
? Boolean(profile.viewer.followedBy)
|
? Boolean(profile.viewer.followedBy)
|
||||||
@@ -323,12 +323,12 @@ function useProfileFollowMutation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useProfileUnfollowMutation(
|
function useProfileUnfollowMutation(
|
||||||
logContext: LogEvents['profile:unfollow']['logContext'],
|
logContext: LogEvents['profile:unfollow:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useMutation<void, Error, {did: string; followUri: string}>({
|
return useMutation<void, Error, {did: string; followUri: string}>({
|
||||||
mutationFn: async ({followUri}) => {
|
mutationFn: async ({followUri}) => {
|
||||||
logEvent('profile:unfollow', {logContext})
|
logEvent('profile:unfollow:sampled', {logContext})
|
||||||
track('Profile:Unfollow', {username: followUri})
|
track('Profile:Unfollow', {username: followUri})
|
||||||
return await agent.deleteFollow(followUri)
|
return await agent.deleteFollow(followUri)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export interface ComposerOpts {
|
|||||||
quote?: ComposerOptsQuote
|
quote?: ComposerOptsQuote
|
||||||
quoteCount?: number
|
quoteCount?: number
|
||||||
mention?: string // handle of user to mention
|
mention?: string // handle of user to mention
|
||||||
openPicker?: (pos: DOMRect | undefined) => void
|
openEmojiPicker?: (pos: DOMRect | undefined) => void
|
||||||
text?: string
|
text?: string
|
||||||
imageUris?: {uri: string; width: number; height: number}[]
|
imageUris?: {uri: string; width: number; height: number}[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
quote: initQuote,
|
quote: initQuote,
|
||||||
quoteCount,
|
quoteCount,
|
||||||
mention: initMention,
|
mention: initMention,
|
||||||
openPicker,
|
openEmojiPicker,
|
||||||
text: initText,
|
text: initText,
|
||||||
imageUris: initImageUris,
|
imageUris: initImageUris,
|
||||||
cancelRef,
|
cancelRef,
|
||||||
@@ -520,8 +520,8 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
|
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
|
||||||
|
|
||||||
const onEmojiButtonPress = useCallback(() => {
|
const onEmojiButtonPress = useCallback(() => {
|
||||||
openPicker?.(textInput.current?.getCursorPosition())
|
openEmojiPicker?.(textInput.current?.getCursorPosition())
|
||||||
}, [openPicker])
|
}, [openEmojiPicker])
|
||||||
|
|
||||||
const focusTextInput = useCallback(() => {
|
const focusTextInput = useCallback(() => {
|
||||||
textInput.current?.focus()
|
textInput.current?.focus()
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import {Placeholder} from '@tiptap/extension-placeholder'
|
|||||||
import {Text as TiptapText} from '@tiptap/extension-text'
|
import {Text as TiptapText} from '@tiptap/extension-text'
|
||||||
import {generateJSON} from '@tiptap/html'
|
import {generateJSON} from '@tiptap/html'
|
||||||
import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
|
import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
|
||||||
import EventEmitter from 'eventemitter3'
|
|
||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
||||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||||
import {blobToDataUri, isUriImage} from 'lib/media/util'
|
import {blobToDataUri, isUriImage} from 'lib/media/util'
|
||||||
|
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||||
import {
|
import {
|
||||||
LinkFacetMatch,
|
LinkFacetMatch,
|
||||||
suggestLinkCardUri,
|
suggestLinkCardUri,
|
||||||
@@ -46,8 +46,6 @@ interface TextInputProps {
|
|||||||
onError: (err: string) => void
|
onError: (err: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const textInputWebEmitter = new EventEmitter()
|
|
||||||
|
|
||||||
export const TextInput = React.forwardRef(function TextInputImpl(
|
export const TextInput = React.forwardRef(function TextInputImpl(
|
||||||
{
|
{
|
||||||
richtext,
|
richtext,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
|
export const textInputWebEmitter = new EventEmitter()
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import Picker from '@emoji-mart/react'
|
import Picker from '@emoji-mart/react'
|
||||||
|
|
||||||
|
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {textInputWebEmitter} from '../TextInput.web'
|
|
||||||
|
|
||||||
const HEIGHT_OFFSET = 40
|
const HEIGHT_OFFSET = 40
|
||||||
const WIDTH_OFFSET = 100
|
const WIDTH_OFFSET = 100
|
||||||
@@ -26,22 +26,41 @@ export type Emoji = {
|
|||||||
unified: string
|
unified: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmojiPickerPosition {
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
right: number
|
||||||
|
bottom: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface EmojiPickerState {
|
export interface EmojiPickerState {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
pos: {top: number; left: number; right: number; bottom: number}
|
pos: EmojiPickerPosition
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
state: EmojiPickerState
|
state: EmojiPickerState
|
||||||
close: () => void
|
close: () => void
|
||||||
|
/**
|
||||||
|
* If `true`, overrides position and ensures picker is pinned to the top of
|
||||||
|
* the target element.
|
||||||
|
*/
|
||||||
|
pinToTop?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmojiPicker({state, close}: IProps) {
|
export function EmojiPicker({state, close, pinToTop}: IProps) {
|
||||||
const {height, width} = useWindowDimensions()
|
const {height, width} = useWindowDimensions()
|
||||||
|
|
||||||
const isShiftDown = React.useRef(false)
|
const isShiftDown = React.useRef(false)
|
||||||
|
|
||||||
const position = React.useMemo(() => {
|
const position = React.useMemo(() => {
|
||||||
|
if (pinToTop) {
|
||||||
|
return {
|
||||||
|
top: state.pos.top - PICKER_HEIGHT + HEIGHT_OFFSET - 10,
|
||||||
|
left: state.pos.left,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fitsBelow = state.pos.top + PICKER_HEIGHT < height
|
const fitsBelow = state.pos.top + PICKER_HEIGHT < height
|
||||||
const fitsAbove = PICKER_HEIGHT < state.pos.top
|
const fitsAbove = PICKER_HEIGHT < state.pos.top
|
||||||
const placeOnLeft = PICKER_WIDTH < state.pos.left
|
const placeOnLeft = PICKER_WIDTH < state.pos.left
|
||||||
@@ -64,7 +83,7 @@ export function EmojiPicker({state, close}: IProps) {
|
|||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [state.pos, height, width])
|
}, [state.pos, height, width, pinToTop])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!state.isOpen) return
|
if (!state.isOpen) return
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function VideoPreview({
|
|||||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||||
{autoplayDisabled && (
|
{autoplayDisabled && (
|
||||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
<PlayButtonIcon size={48} />
|
<PlayButtonIcon />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function VideoPreview({
|
|||||||
/>
|
/>
|
||||||
{autoplayDisabled && (
|
{autoplayDisabled && (
|
||||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
<PlayButtonIcon size={48} />
|
<PlayButtonIcon />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import {ScrollView, View} from 'react-native'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
|
||||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
|
||||||
import {isWeb} from 'platform/detection'
|
|
||||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
|
|
||||||
const OUTER_PADDING = a.p_md.padding
|
|
||||||
const INNER_PADDING = a.p_lg.padding
|
|
||||||
const TOTAL_HEIGHT = 232
|
|
||||||
const MOBILE_CARD_WIDTH = 300
|
|
||||||
|
|
||||||
function CardOuter({
|
|
||||||
children,
|
|
||||||
style,
|
|
||||||
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
|
|
||||||
const t = useTheme()
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.w_full,
|
|
||||||
a.p_lg,
|
|
||||||
a.rounded_md,
|
|
||||||
a.border,
|
|
||||||
t.atoms.bg,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
{
|
|
||||||
width: MOBILE_CARD_WIDTH,
|
|
||||||
},
|
|
||||||
style,
|
|
||||||
]}>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SuggestedFollowPlaceholder() {
|
|
||||||
const t = useTheme()
|
|
||||||
return (
|
|
||||||
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
|
||||||
<ProfileCard.Header>
|
|
||||||
<ProfileCard.AvatarPlaceholder />
|
|
||||||
<ProfileCard.NameAndHandlePlaceholder />
|
|
||||||
</ProfileCard.Header>
|
|
||||||
|
|
||||||
<ProfileCard.DescriptionPlaceholder />
|
|
||||||
</CardOuter>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfileHeaderSuggestedFollows({
|
|
||||||
actorDid,
|
|
||||||
requestDismiss,
|
|
||||||
}: {
|
|
||||||
actorDid: string
|
|
||||||
requestDismiss: () => void
|
|
||||||
}) {
|
|
||||||
const t = useTheme()
|
|
||||||
const {_} = useLingui()
|
|
||||||
const {isLoading: isSuggestionsLoading, data} =
|
|
||||||
useSuggestedFollowsByActorQuery({
|
|
||||||
did: actorDid,
|
|
||||||
})
|
|
||||||
const moderationOpts = useModerationOpts()
|
|
||||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{paddingVertical: OUTER_PADDING, height: TOTAL_HEIGHT}}
|
|
||||||
pointerEvents="box-none">
|
|
||||||
<View
|
|
||||||
pointerEvents="box-none"
|
|
||||||
style={[
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
{
|
|
||||||
height: '100%',
|
|
||||||
paddingTop: INNER_PADDING / 2,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<View
|
|
||||||
pointerEvents="box-none"
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
a.justify_between,
|
|
||||||
a.align_center,
|
|
||||||
a.pt_xs,
|
|
||||||
{
|
|
||||||
paddingBottom: INNER_PADDING / 2,
|
|
||||||
paddingLeft: INNER_PADDING,
|
|
||||||
paddingRight: INNER_PADDING / 2,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
|
||||||
<Trans>Similar accounts</Trans>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onPress={requestDismiss}
|
|
||||||
hitSlop={10}
|
|
||||||
label={_(msg`Dismiss`)}
|
|
||||||
size="xsmall"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
shape="round">
|
|
||||||
<ButtonIcon icon={X} size="sm" />
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
horizontal={true}
|
|
||||||
showsHorizontalScrollIndicator={isWeb}
|
|
||||||
persistentScrollbar={true}
|
|
||||||
scrollIndicatorInsets={{bottom: 0}}
|
|
||||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_sm.gap}
|
|
||||||
decelerationRate="fast">
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
a.gap_sm,
|
|
||||||
{
|
|
||||||
paddingHorizontal: INNER_PADDING,
|
|
||||||
paddingBottom: INNER_PADDING,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<SuggestedFollowPlaceholder />
|
|
||||||
<SuggestedFollowPlaceholder />
|
|
||||||
<SuggestedFollowPlaceholder />
|
|
||||||
<SuggestedFollowPlaceholder />
|
|
||||||
<SuggestedFollowPlaceholder />
|
|
||||||
</>
|
|
||||||
) : data ? (
|
|
||||||
data.suggestions
|
|
||||||
.filter(s => (s.associated?.labeler ? false : true))
|
|
||||||
.map(profile => (
|
|
||||||
<ProfileCard.Link
|
|
||||||
key={profile.did}
|
|
||||||
profile={profile}
|
|
||||||
onPress={() => {
|
|
||||||
logEvent('profile:header:suggestedFollowsCard:press', {})
|
|
||||||
}}
|
|
||||||
style={[a.flex_1]}>
|
|
||||||
{({hovered, pressed}) => (
|
|
||||||
<CardOuter
|
|
||||||
style={[
|
|
||||||
a.flex_1,
|
|
||||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
|
||||||
]}>
|
|
||||||
<ProfileCard.Outer>
|
|
||||||
<ProfileCard.Header>
|
|
||||||
<ProfileCard.Avatar
|
|
||||||
profile={profile}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
/>
|
|
||||||
<ProfileCard.NameAndHandle
|
|
||||||
profile={profile}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
/>
|
|
||||||
<ProfileCard.FollowButton
|
|
||||||
profile={profile}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
logContext="ProfileHeaderSuggestedFollows"
|
|
||||||
color="secondary_inverted"
|
|
||||||
shape="round"
|
|
||||||
/>
|
|
||||||
</ProfileCard.Header>
|
|
||||||
<ProfileCard.Description profile={profile} />
|
|
||||||
</ProfileCard.Outer>
|
|
||||||
</CardOuter>
|
|
||||||
)}
|
|
||||||
</ProfileCard.Link>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<View />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import EventEmitter from 'eventemitter3'
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {useMinimalShellMode} from '#/state/shell'
|
import {useMinimalShellMode} from '#/state/shell'
|
||||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||||
import {isNative, isWeb} from 'platform/detection'
|
import {isNative, isWeb} from 'platform/detection'
|
||||||
@@ -23,12 +22,10 @@ function clamp(num: number, min: number, max: number) {
|
|||||||
|
|
||||||
export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||||
const {headerHeight} = useShellLayout()
|
const {headerHeight} = useShellLayout()
|
||||||
const {headerMode, footerMode} = useMinimalShellMode()
|
const {headerMode} = useMinimalShellMode()
|
||||||
const startDragOffset = useSharedValue<number | null>(null)
|
const startDragOffset = useSharedValue<number | null>(null)
|
||||||
const startMode = useSharedValue<number | null>(null)
|
const startMode = useSharedValue<number | null>(null)
|
||||||
const didJustRestoreScroll = useSharedValue<boolean>(false)
|
const didJustRestoreScroll = useSharedValue<boolean>(false)
|
||||||
const gate = useGate()
|
|
||||||
const isFixedBottomBar = gate('fixed_bottom_bar')
|
|
||||||
|
|
||||||
const setMode = React.useCallback(
|
const setMode = React.useCallback(
|
||||||
(v: boolean) => {
|
(v: boolean) => {
|
||||||
@@ -37,14 +34,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
|||||||
headerMode.value = withSpring(v ? 1 : 0, {
|
headerMode.value = withSpring(v ? 1 : 0, {
|
||||||
overshootClamping: true,
|
overshootClamping: true,
|
||||||
})
|
})
|
||||||
if (!isFixedBottomBar) {
|
|
||||||
cancelAnimation(footerMode)
|
|
||||||
footerMode.value = withSpring(v ? 1 : 0, {
|
|
||||||
overshootClamping: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[headerMode, footerMode, isFixedBottomBar],
|
[headerMode],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -147,10 +138,6 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
|||||||
// Cancel any any existing animation
|
// Cancel any any existing animation
|
||||||
cancelAnimation(headerMode)
|
cancelAnimation(headerMode)
|
||||||
headerMode.value = newValue
|
headerMode.value = newValue
|
||||||
if (!isFixedBottomBar) {
|
|
||||||
cancelAnimation(footerMode)
|
|
||||||
footerMode.value = newValue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (didJustRestoreScroll.value) {
|
if (didJustRestoreScroll.value) {
|
||||||
@@ -173,12 +160,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
|||||||
[
|
[
|
||||||
headerHeight,
|
headerHeight,
|
||||||
headerMode,
|
headerMode,
|
||||||
footerMode,
|
|
||||||
setMode,
|
setMode,
|
||||||
startDragOffset,
|
startDragOffset,
|
||||||
startMode,
|
startMode,
|
||||||
didJustRestoreScroll,
|
didJustRestoreScroll,
|
||||||
isFixedBottomBar,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ function Toast({
|
|||||||
a.flex_1,
|
a.flex_1,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
a.shadow_lg,
|
a.shadow_lg,
|
||||||
a.rounded_sm,
|
|
||||||
t.atoms.border_contrast_medium,
|
t.atoms.border_contrast_medium,
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
a.px_md,
|
a.px_md,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const styles = StyleSheet.create({
|
|||||||
// @ts-ignore web-only
|
// @ts-ignore web-only
|
||||||
position: isWeb ? 'fixed' : 'absolute',
|
position: isWeb ? 'fixed' : 'absolute',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
inner: {
|
inner: {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {useVideoPlayer, VideoPlayer} from 'expo-video'
|
import {useVideoPlayer, VideoPlayer} from 'expo-video'
|
||||||
|
|
||||||
import {isNative} from '#/platform/detection'
|
import {isAndroid, isNative} from '#/platform/detection'
|
||||||
|
|
||||||
const Context = React.createContext<{
|
const Context = React.createContext<{
|
||||||
activeSource: string
|
activeSource: string
|
||||||
@@ -26,7 +26,18 @@ export function Provider({children}: {children: React.ReactNode}) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const setActiveSourceOuter = (src: string | null, viewId: string | null) => {
|
const setActiveSourceOuter = (src: string | null, viewId: string | null) => {
|
||||||
setActiveSource(src ? src : '')
|
// HACK
|
||||||
|
// expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually
|
||||||
|
// unregister that player to let the new screen register it. This is only a problem on Android, so we only need to
|
||||||
|
// apply it there.
|
||||||
|
if (src === activeSource && isAndroid) {
|
||||||
|
setActiveSource('')
|
||||||
|
setTimeout(() => {
|
||||||
|
setActiveSource(src ? src : '')
|
||||||
|
}, 100)
|
||||||
|
} else {
|
||||||
|
setActiveSource(src ? src : '')
|
||||||
|
}
|
||||||
setActiveViewId(viewId ? viewId : '')
|
setActiveViewId(viewId ? viewId : '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ function InnerWrapper({embed}: Props) {
|
|||||||
|
|
||||||
const [playerStatus, setPlayerStatus] = useState<
|
const [playerStatus, setPlayerStatus] = useState<
|
||||||
VideoPlayerStatus | 'paused'
|
VideoPlayerStatus | 'paused'
|
||||||
>(player.playing ? 'readyToPlay' : 'paused')
|
>('paused')
|
||||||
const [isMuted, setIsMuted] = useState(player.muted)
|
const [isMuted, setIsMuted] = useState(player.muted)
|
||||||
const [isFullscreen, setIsFullscreen] = React.useState(false)
|
const [isFullscreen, setIsFullscreen] = React.useState(false)
|
||||||
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {HITSLOP_30} from '#/lib/constants'
|
import {HITSLOP_30} from '#/lib/constants'
|
||||||
import {clamp} from '#/lib/numbers'
|
import {clamp} from '#/lib/numbers'
|
||||||
|
import {isAndroid} from 'platform/detection'
|
||||||
import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
|
import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||||
@@ -61,6 +62,9 @@ export function VideoEmbedInnerNative({
|
|||||||
PlatformInfo.setAudioActive(true)
|
PlatformInfo.setAudioActive(true)
|
||||||
player.muted = false
|
player.muted = false
|
||||||
setIsFullscreen(true)
|
setIsFullscreen(true)
|
||||||
|
if (isAndroid) {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onFullscreenExit={() => {
|
onFullscreenExit={() => {
|
||||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {ActivityIndicator, AppState, StyleSheet, View} from 'react-native'
|
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
|
||||||
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
|
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {emitSoftReset} from '#/state/events'
|
import {emitSoftReset} from '#/state/events'
|
||||||
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
|
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||||
import {FeedParams} from '#/state/queries/post-feed'
|
import {FeedParams} from '#/state/queries/post-feed'
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {
|
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
|
||||||
useMinimalShellMode,
|
|
||||||
useSetDrawerSwipeDisabled,
|
|
||||||
useSetMinimalShellMode,
|
|
||||||
} from '#/state/shell'
|
|
||||||
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
|
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
|
||||||
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
|
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
|
||||||
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
||||||
@@ -87,7 +81,6 @@ function HomeScreenReady({
|
|||||||
const selectedIndex = Math.max(0, maybeFoundIndex)
|
const selectedIndex = Math.max(0, maybeFoundIndex)
|
||||||
const selectedFeed = allFeeds[selectedIndex]
|
const selectedFeed = allFeeds[selectedIndex]
|
||||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||||
const gate = useGate()
|
|
||||||
|
|
||||||
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
|
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
|
||||||
useOTAUpdates()
|
useOTAUpdates()
|
||||||
@@ -134,29 +127,6 @@ function HomeScreenReady({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const {footerMode} = useMinimalShellMode()
|
|
||||||
const {isMobile} = useWebMediaQueries()
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
if (gate('fixed_bottom_bar')) {
|
|
||||||
// Unnecessary because it's always there.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const listener = AppState.addEventListener('change', nextAppState => {
|
|
||||||
if (nextAppState === 'active') {
|
|
||||||
if (isMobile && footerMode.value === 1) {
|
|
||||||
// Reveal the bottom bar so you don't miss notifications or messages.
|
|
||||||
// TODO: Experiment with only doing it when unread > 0.
|
|
||||||
setMinimalShellMode(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
listener.remove()
|
|
||||||
}
|
|
||||||
}, [setMinimalShellMode, footerMode, isMobile, gate]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const onPageSelected = React.useCallback(
|
const onPageSelected = React.useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
setMinimalShellMode(false)
|
setMinimalShellMode(false)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function Composer({}: {winHeight: number}) {
|
|||||||
quoteCount={state?.quoteCount}
|
quoteCount={state?.quoteCount}
|
||||||
onPost={state.onPost}
|
onPost={state.onPost}
|
||||||
mention={state.mention}
|
mention={state.mention}
|
||||||
openPicker={onOpenPicker}
|
openEmojiPicker={onOpenPicker}
|
||||||
text={state.text}
|
text={state.text}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import Animated from 'react-native-reanimated'
|
|||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import * as NavigationBar from 'expo-navigation-bar'
|
import * as NavigationBar from 'expo-navigation-bar'
|
||||||
import {StatusBar} from 'expo-status-bar'
|
import {StatusBar} from 'expo-status-bar'
|
||||||
import {useNavigationState} from '@react-navigation/native'
|
import {useNavigation, useNavigationState} from '@react-navigation/native'
|
||||||
|
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
useSetDrawerOpen,
|
useSetDrawerOpen,
|
||||||
} from '#/state/shell'
|
} from '#/state/shell'
|
||||||
import {useCloseAnyActiveElement} from '#/state/util'
|
import {useCloseAnyActiveElement} from '#/state/util'
|
||||||
|
import {useDedupe} from 'lib/hooks/useDedupe'
|
||||||
import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler'
|
import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useNotificationsRegistration} from 'lib/notifications/notifications'
|
import {useNotificationsRegistration} from 'lib/notifications/notifications'
|
||||||
@@ -33,6 +34,7 @@ import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
|
|||||||
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||||
import {SigninDialog} from '#/components/dialogs/Signin'
|
import {SigninDialog} from '#/components/dialogs/Signin'
|
||||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||||
|
import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
|
||||||
import {RoutesContainer, TabsNavigator} from '../../Navigation'
|
import {RoutesContainer, TabsNavigator} from '../../Navigation'
|
||||||
import {Composer} from './Composer'
|
import {Composer} from './Composer'
|
||||||
import {DrawerContent} from './Drawer'
|
import {DrawerContent} from './Drawer'
|
||||||
@@ -76,6 +78,27 @@ function ShellInner() {
|
|||||||
}
|
}
|
||||||
}, [closeAnyActiveElement])
|
}, [closeAnyActiveElement])
|
||||||
|
|
||||||
|
// HACK
|
||||||
|
// expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually
|
||||||
|
// unregister that player to let the new screen register it. This is only a problem on Android, so we only need to
|
||||||
|
// apply it there.
|
||||||
|
// The `state` event should only fire whenever we push or pop to a screen, and should not fire consecutively quickly.
|
||||||
|
// To be certain though, we will also dedupe these calls.
|
||||||
|
const navigation = useNavigation()
|
||||||
|
const dedupe = useDedupe(1000)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isAndroid) return
|
||||||
|
const onFocusOrBlur = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
dedupe(updateActiveViewAsync)
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
navigation.addListener('state', onFocusOrBlur)
|
||||||
|
return () => {
|
||||||
|
navigation.removeListener('state', onFocusOrBlur)
|
||||||
|
}
|
||||||
|
}, [dedupe, navigation])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
|||||||
@@ -12414,9 +12414,9 @@ expo-updates@~0.25.14:
|
|||||||
ignore "^5.3.1"
|
ignore "^5.3.1"
|
||||||
resolve-from "^5.0.0"
|
resolve-from "^5.0.0"
|
||||||
|
|
||||||
"expo-video@https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz":
|
"expo-video@https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz":
|
||||||
version "1.2.4"
|
version "1.2.4"
|
||||||
resolved "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz#57f61a72f41b86e5a587d9782d32bd32487a551e"
|
resolved "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz#4127dd5cea5fdf7ab745104c73b8ecf5506f5d34"
|
||||||
|
|
||||||
expo-web-browser@~13.0.3:
|
expo-web-browser@~13.0.3:
|
||||||
version "13.0.3"
|
version "13.0.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user