Merge branch 'main' into patch-3
This commit is contained in:
commit
e74c46e9ab
21 changed files with 156 additions and 212 deletions
|
@ -31,7 +31,7 @@ RUN \. "$NVM_DIR/nvm.sh" && \
|
||||||
nvm use $NODE_VERSION && \
|
nvm use $NODE_VERSION && \
|
||||||
npm install --global yarn && \
|
npm install --global yarn && \
|
||||||
yarn && \
|
yarn && \
|
||||||
yarn intl:compile && \
|
yarn intl:build && \
|
||||||
yarn build-web
|
yarn build-web
|
||||||
|
|
||||||
# DEBUG
|
# DEBUG
|
||||||
|
|
|
@ -153,10 +153,11 @@ module.exports = function (config) {
|
||||||
'expo-notifications',
|
'expo-notifications',
|
||||||
{
|
{
|
||||||
icon: './assets/icon-android-notification.png',
|
icon: './assets/icon-android-notification.png',
|
||||||
color: '#ffffff',
|
color: '#1185fe',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'./plugins/withAndroidManifestPlugin.js',
|
'./plugins/withAndroidManifestPlugin.js',
|
||||||
|
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||||
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
|
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
|
||||||
'./plugins/shareExtension/withShareExtensions.js',
|
'./plugins/shareExtension/withShareExtensions.js',
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
|
|
|
@ -1,70 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Mailmodo struct {
|
|
||||||
httpClient *http.Client
|
|
||||||
APIKey string
|
|
||||||
BaseURL string
|
|
||||||
ListName string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMailmodo(apiKey, listName string) *Mailmodo {
|
|
||||||
return &Mailmodo{
|
|
||||||
APIKey: apiKey,
|
|
||||||
BaseURL: "https://api.mailmodo.com/api/v1",
|
|
||||||
httpClient: &http.Client{},
|
|
||||||
ListName: listName,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Mailmodo) request(ctx context.Context, httpMethod string, apiMethod string, data any) error {
|
|
||||||
endpoint := fmt.Sprintf("%s/%s", m.BaseURL, apiMethod)
|
|
||||||
js, err := json.Marshal(data)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Mailmodo JSON encoding failed: %w", err)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, httpMethod, endpoint, bytes.NewBuffer(js))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Mailmodo HTTP creating request %s %s failed: %w", httpMethod, apiMethod, err)
|
|
||||||
}
|
|
||||||
req.Header.Set("mmApiKey", m.APIKey)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
res, err := m.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Mailmodo HTTP making request %s %s failed: %w", httpMethod, apiMethod, err)
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
status := struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}{}
|
|
||||||
if err := json.NewDecoder(res.Body).Decode(&status); err != nil {
|
|
||||||
return fmt.Errorf("Mailmodo HTTP parsing response %s %s failed: %w", httpMethod, apiMethod, err)
|
|
||||||
}
|
|
||||||
if !status.Success {
|
|
||||||
return fmt.Errorf("Mailmodo API response %s %s failed: %s", httpMethod, apiMethod, status.Message)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Mailmodo) AddToList(ctx context.Context, email string) error {
|
|
||||||
return m.request(ctx, "POST", "addToList", map[string]any{
|
|
||||||
"listName": m.ListName,
|
|
||||||
"email": email,
|
|
||||||
"data": map[string]any{
|
|
||||||
"email_hashed": fmt.Sprintf("%x", sha256.Sum256([]byte(email))),
|
|
||||||
},
|
|
||||||
"created_at": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -40,18 +40,6 @@ func run(args []string) {
|
||||||
// retain old PDS env var for easy transition
|
// retain old PDS env var for easy transition
|
||||||
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
|
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "mailmodo-api-key",
|
|
||||||
Usage: "Mailmodo API key",
|
|
||||||
Required: false,
|
|
||||||
EnvVars: []string{"MAILMODO_API_KEY"},
|
|
||||||
},
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "mailmodo-list-name",
|
|
||||||
Usage: "Mailmodo contact list to add email addresses to",
|
|
||||||
Required: false,
|
|
||||||
EnvVars: []string{"MAILMODO_LIST_NAME"},
|
|
||||||
},
|
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "http-address",
|
Name: "http-address",
|
||||||
Usage: "Specify the local IP/port to bind to",
|
Usage: "Specify the local IP/port to bind to",
|
||||||
|
|
|
@ -2,11 +2,9 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
@ -29,25 +27,19 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
echo *echo.Echo
|
echo *echo.Echo
|
||||||
httpd *http.Server
|
httpd *http.Server
|
||||||
mailmodo *Mailmodo
|
xrpcc *xrpc.Client
|
||||||
xrpcc *xrpc.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func serve(cctx *cli.Context) error {
|
func serve(cctx *cli.Context) error {
|
||||||
debug := cctx.Bool("debug")
|
debug := cctx.Bool("debug")
|
||||||
httpAddress := cctx.String("http-address")
|
httpAddress := cctx.String("http-address")
|
||||||
appviewHost := cctx.String("appview-host")
|
appviewHost := cctx.String("appview-host")
|
||||||
mailmodoAPIKey := cctx.String("mailmodo-api-key")
|
|
||||||
mailmodoListName := cctx.String("mailmodo-list-name")
|
|
||||||
|
|
||||||
// Echo
|
// Echo
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
// Mailmodo client.
|
|
||||||
mailmodo := NewMailmodo(mailmodoAPIKey, mailmodoListName)
|
|
||||||
|
|
||||||
// create a new session (no auth)
|
// create a new session (no auth)
|
||||||
xrpcc := &xrpc.Client{
|
xrpcc := &xrpc.Client{
|
||||||
Client: cliutil.NewHttpClient(),
|
Client: cliutil.NewHttpClient(),
|
||||||
|
@ -77,9 +69,8 @@ func serve(cctx *cli.Context) error {
|
||||||
// server
|
// server
|
||||||
//
|
//
|
||||||
server := &Server{
|
server := &Server{
|
||||||
echo: e,
|
echo: e,
|
||||||
mailmodo: mailmodo,
|
xrpcc: xrpcc,
|
||||||
xrpcc: xrpcc,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the HTTP server.
|
// Create the HTTP server.
|
||||||
|
@ -221,9 +212,6 @@ func serve(cctx *cli.Context) error {
|
||||||
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
|
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
|
||||||
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
|
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
|
||||||
|
|
||||||
// Mailmodo
|
|
||||||
e.POST("/api/waitlist", server.apiWaitlist)
|
|
||||||
|
|
||||||
// Start the server.
|
// Start the server.
|
||||||
log.Infof("starting server address=%s", httpAddress)
|
log.Infof("starting server address=%s", httpAddress)
|
||||||
go func() {
|
go func() {
|
||||||
|
@ -398,36 +386,3 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) apiWaitlist(c echo.Context) error {
|
|
||||||
type jsonError struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the API request.
|
|
||||||
type apiRequest struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyReader := http.MaxBytesReader(c.Response(), c.Request().Body, 16*1024)
|
|
||||||
payload, err := ioutil.ReadAll(bodyReader)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var req apiRequest
|
|
||||||
if err := json.Unmarshal(payload, &req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, jsonError{Error: "Invalid API request"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Email == "" {
|
|
||||||
return c.JSON(http.StatusBadRequest, jsonError{Error: "Please enter a valid email address."})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := srv.mailmodo.AddToList(c.Request().Context(), req.Email); err != nil {
|
|
||||||
log.Errorf("adding email to waitlist failed: %s", err)
|
|
||||||
return c.JSON(http.StatusBadRequest, jsonError{
|
|
||||||
Error: "Storing email in waitlist failed. Please enter a valid email address.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, map[string]bool{"success": true})
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.71.0",
|
"version": "1.72.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
@ -44,7 +44,7 @@
|
||||||
"update-extensions": "scripts/updateExtensions.sh"
|
"update-extensions": "scripts/updateExtensions.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.10.4",
|
"@atproto/api": "^0.10.5",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
@ -63,7 +63,6 @@
|
||||||
"@react-native-camera-roll/camera-roll": "^5.2.2",
|
"@react-native-camera-roll/camera-roll": "^5.2.2",
|
||||||
"@react-native-clipboard/clipboard": "^1.10.0",
|
"@react-native-clipboard/clipboard": "^1.10.0",
|
||||||
"@react-native-community/blur": "^4.3.0",
|
"@react-native-community/blur": "^4.3.0",
|
||||||
"@react-native-community/datetimepicker": "7.6.1",
|
|
||||||
"@react-native-masked-view/masked-view": "0.3.0",
|
"@react-native-masked-view/masked-view": "0.3.0",
|
||||||
"@react-native-menu/menu": "^0.8.0",
|
"@react-native-menu/menu": "^0.8.0",
|
||||||
"@react-native-picker/picker": "2.6.1",
|
"@react-native-picker/picker": "2.6.1",
|
||||||
|
@ -152,6 +151,7 @@
|
||||||
"react-keyed-flatten-children": "^3.0.0",
|
"react-keyed-flatten-children": "^3.0.0",
|
||||||
"react-native": "0.73.2",
|
"react-native": "0.73.2",
|
||||||
"react-native-appstate-hook": "^1.0.6",
|
"react-native-appstate-hook": "^1.0.6",
|
||||||
|
"react-native-date-picker": "^4.4.0",
|
||||||
"react-native-drawer-layout": "^4.0.0-alpha.3",
|
"react-native-drawer-layout": "^4.0.0-alpha.3",
|
||||||
"react-native-fs": "^2.20.0",
|
"react-native-fs": "^2.20.0",
|
||||||
"react-native-gesture-handler": "~2.14.0",
|
"react-native-gesture-handler": "~2.14.0",
|
||||||
|
|
37
plugins/withAndroidManifestFCMIconPlugin.js
Normal file
37
plugins/withAndroidManifestFCMIconPlugin.js
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
const {withAndroidManifest} = require('expo/config-plugins')
|
||||||
|
|
||||||
|
module.exports = function withAndroidManifestFCMIconPlugin(appConfig) {
|
||||||
|
return withAndroidManifest(appConfig, function (decoratedAppConfig) {
|
||||||
|
try {
|
||||||
|
function addOrModifyMetaData(metaData, name, resource) {
|
||||||
|
const elem = metaData.find(elem => elem.$['android:name'] === name)
|
||||||
|
if (elem === undefined) {
|
||||||
|
metaData.push({
|
||||||
|
$: {
|
||||||
|
'android:name': name,
|
||||||
|
'android:resource': resource,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
elem.$['android:resource'] = resource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const androidManifest = decoratedAppConfig.modResults.manifest
|
||||||
|
const metaData = androidManifest.application[0]['meta-data']
|
||||||
|
addOrModifyMetaData(
|
||||||
|
metaData,
|
||||||
|
'com.google.firebase.messaging.default_notification_color',
|
||||||
|
'@color/notification_icon_color',
|
||||||
|
)
|
||||||
|
addOrModifyMetaData(
|
||||||
|
metaData,
|
||||||
|
'com.google.firebase.messaging.default_notification_icon',
|
||||||
|
'@drawable/notification_icon',
|
||||||
|
)
|
||||||
|
return decoratedAppConfig
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`withAndroidManifestFCMIconPlugin failed`, e)
|
||||||
|
}
|
||||||
|
return decoratedAppConfig
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
|
import {CenteredView} from 'view/com/util/Views'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Trans, msg} from '@lingui/macro'
|
import {Trans, msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
@ -145,7 +146,7 @@ export function ListMaybePlaceholder({
|
||||||
}) {
|
}) {
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile, gtTablet} = useBreakpoints()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
const canGoBack = navigation.canGoBack()
|
const canGoBack = navigation.canGoBack()
|
||||||
|
@ -168,14 +169,16 @@ export function ListMaybePlaceholder({
|
||||||
if (!isEmpty) return null
|
if (!isEmpty) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<CenteredView
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
!gtMobile ? [a.justify_between, a.border_t] : a.gap_5xl,
|
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
{paddingTop: 175, paddingBottom: 110},
|
{paddingTop: 175, paddingBottom: 110},
|
||||||
]}>
|
]}
|
||||||
|
sideBorders={gtMobile}
|
||||||
|
topBorder={!gtTablet}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View style={[a.w_full, a.align_center, {top: 100}]}>
|
<View style={[a.w_full, a.align_center, {top: 100}]}>
|
||||||
<Loader size="xl" />
|
<Loader size="xl" />
|
||||||
|
@ -244,6 +247,6 @@ export function ListMaybePlaceholder({
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</CenteredView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View, Pressable} from 'react-native'
|
import {View, Pressable} from 'react-native'
|
||||||
import DateTimePicker, {
|
|
||||||
BaseProps as DateTimePickerProps,
|
|
||||||
} from '@react-native-community/datetimepicker'
|
|
||||||
|
|
||||||
import {useTheme, atoms} from '#/alf'
|
import {useTheme, atoms} from '#/alf'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
@ -15,6 +12,8 @@ import {
|
||||||
localizeDate,
|
localizeDate,
|
||||||
toSimpleDateString,
|
toSimpleDateString,
|
||||||
} from '#/components/forms/DateField/utils'
|
} from '#/components/forms/DateField/utils'
|
||||||
|
import DatePicker from 'react-native-date-picker'
|
||||||
|
import {isAndroid} from 'platform/detection'
|
||||||
|
|
||||||
export * as utils from '#/components/forms/DateField/utils'
|
export * as utils from '#/components/forms/DateField/utils'
|
||||||
export const Label = TextField.Label
|
export const Label = TextField.Label
|
||||||
|
@ -38,20 +37,20 @@ export function DateField({
|
||||||
const {chromeFocus, chromeError, chromeErrorHover} =
|
const {chromeFocus, chromeError, chromeErrorHover} =
|
||||||
TextField.useSharedInputStyles()
|
TextField.useSharedInputStyles()
|
||||||
|
|
||||||
const onChangeInternal = React.useCallback<
|
const onChangeInternal = React.useCallback(
|
||||||
Required<DateTimePickerProps>['onChange']
|
(date: Date) => {
|
||||||
>(
|
|
||||||
(_event, date) => {
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
|
||||||
if (date) {
|
const formatted = toSimpleDateString(date)
|
||||||
const formatted = toSimpleDateString(date)
|
onChangeDate(formatted)
|
||||||
onChangeDate(formatted)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onChangeDate, setOpen],
|
[onChangeDate, setOpen],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onCancel = React.useCallback(() => {
|
||||||
|
setOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[atoms.relative, atoms.w_full]}>
|
<View style={[atoms.relative, atoms.w_full]}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
@ -89,18 +88,18 @@ export function DateField({
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<DateTimePicker
|
<DatePicker
|
||||||
|
modal={isAndroid}
|
||||||
|
open={isAndroid}
|
||||||
|
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||||
|
date={new Date(value)}
|
||||||
|
onConfirm={onChangeInternal}
|
||||||
|
onCancel={onCancel}
|
||||||
|
mode="date"
|
||||||
|
testID={`${testID}-datepicker`}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
accessibilityHint={undefined}
|
accessibilityHint={undefined}
|
||||||
testID={`${testID}-datepicker`}
|
|
||||||
mode="date"
|
|
||||||
timeZoneName={'Etc/UTC'}
|
|
||||||
display="spinner"
|
|
||||||
// @ts-ignore applies in iOS only -prf
|
|
||||||
themeVariant={t.name === 'light' ? 'light' : 'dark'}
|
|
||||||
value={new Date(value)}
|
|
||||||
onChange={onChangeInternal}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import DateTimePicker, {
|
|
||||||
DateTimePickerEvent,
|
|
||||||
} from '@react-native-community/datetimepicker'
|
|
||||||
|
|
||||||
import {useTheme, atoms} from '#/alf'
|
import {useTheme, atoms} from '#/alf'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||||
|
import DatePicker from 'react-native-date-picker'
|
||||||
|
|
||||||
export * as utils from '#/components/forms/DateField/utils'
|
export * as utils from '#/components/forms/DateField/utils'
|
||||||
export const Label = TextField.Label
|
export const Label = TextField.Label
|
||||||
|
@ -28,7 +26,7 @@ export function DateField({
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const onChangeInternal = React.useCallback(
|
const onChangeInternal = React.useCallback(
|
||||||
(event: DateTimePickerEvent, date: Date | undefined) => {
|
(date: Date | undefined) => {
|
||||||
if (date) {
|
if (date) {
|
||||||
const formatted = toSimpleDateString(date)
|
const formatted = toSimpleDateString(date)
|
||||||
onChangeDate(formatted)
|
onChangeDate(formatted)
|
||||||
|
@ -39,17 +37,15 @@ export function DateField({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[atoms.relative, atoms.w_full]}>
|
<View style={[atoms.relative, atoms.w_full]}>
|
||||||
<DateTimePicker
|
<DatePicker
|
||||||
|
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||||
|
date={new Date(value)}
|
||||||
|
onDateChange={onChangeInternal}
|
||||||
|
mode="date"
|
||||||
|
testID={`${testID}-datepicker`}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
accessibilityHint={undefined}
|
accessibilityHint={undefined}
|
||||||
testID={`${testID}-datepicker`}
|
|
||||||
mode="date"
|
|
||||||
timeZoneName={'Etc/UTC'}
|
|
||||||
display="spinner"
|
|
||||||
themeVariant={t.name === 'light' ? 'light' : 'dark'}
|
|
||||||
value={new Date(value)}
|
|
||||||
onChange={onChangeInternal}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
11
src/lib/hooks/useInitialNumToRender.ts
Normal file
11
src/lib/hooks/useInitialNumToRender.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {Dimensions} from 'react-native'
|
||||||
|
|
||||||
|
const MIN_POST_HEIGHT = 100
|
||||||
|
|
||||||
|
export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) {
|
||||||
|
return React.useMemo(() => {
|
||||||
|
const screenHeight = Dimensions.get('window').height
|
||||||
|
return Math.ceil(screenHeight / minItemHeight) + 1
|
||||||
|
}, [minItemHeight])
|
||||||
|
}
|
|
@ -148,6 +148,11 @@ export function feedUriToHref(url: string): string {
|
||||||
export function linkRequiresWarning(uri: string, label: string) {
|
export function linkRequiresWarning(uri: string, label: string) {
|
||||||
const labelDomain = labelToDomain(label)
|
const labelDomain = labelToDomain(label)
|
||||||
|
|
||||||
|
// If the uri started with a / we know it is internal.
|
||||||
|
if (uri.startsWith('/')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
let urip
|
let urip
|
||||||
try {
|
try {
|
||||||
urip = new URL(uri)
|
urip = new URL(uri)
|
||||||
|
@ -156,9 +161,12 @@ export function linkRequiresWarning(uri: string, label: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = urip.hostname.toLowerCase()
|
const host = urip.hostname.toLowerCase()
|
||||||
|
|
||||||
// Hosts that end with bsky.app or bsky.social should be trusted by default.
|
// Hosts that end with bsky.app or bsky.social should be trusted by default.
|
||||||
if (host.endsWith('bsky.app') || host.endsWith('bsky.social')) {
|
if (
|
||||||
|
host.endsWith('bsky.app') ||
|
||||||
|
host.endsWith('bsky.social') ||
|
||||||
|
host.endsWith('blueskyweb.xyz')
|
||||||
|
) {
|
||||||
// if this is a link to internal content,
|
// if this is a link to internal content,
|
||||||
// warn if it represents itself as a URL to another app
|
// warn if it represents itself as a URL to another app
|
||||||
return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
|
return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {ListRenderItemInfo, Pressable} from 'react-native'
|
import {ListRenderItemInfo, Pressable} from 'react-native'
|
||||||
import {atoms as a, useBreakpoints} from '#/alf'
|
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
import {useSetMinimalShellMode} from 'state/shell'
|
import {useSetMinimalShellMode} from 'state/shell'
|
||||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||||
|
@ -19,11 +18,11 @@ import {List} from 'view/com/util/List'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
import {CenteredView} from 'view/com/util/Views'
|
|
||||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox'
|
import {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox'
|
||||||
import {shareUrl} from 'lib/sharing'
|
import {shareUrl} from 'lib/sharing'
|
||||||
import {HITSLOP_10} from 'lib/constants'
|
import {HITSLOP_10} from 'lib/constants'
|
||||||
import {isNative} from 'platform/detection'
|
import {isNative} from 'platform/detection'
|
||||||
|
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||||
|
|
||||||
const renderItem = ({item}: ListRenderItemInfo<PostView>) => {
|
const renderItem = ({item}: ListRenderItemInfo<PostView>) => {
|
||||||
return <Post post={item} />
|
return <Post post={item} />
|
||||||
|
@ -38,8 +37,8 @@ export default function HashtagScreen({
|
||||||
}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) {
|
}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) {
|
||||||
const {tag, author} = route.params
|
const {tag, author} = route.params
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
const {gtMobile} = useBreakpoints()
|
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const initialNumToRender = useInitialNumToRender()
|
||||||
const [isPTR, setIsPTR] = React.useState(false)
|
const [isPTR, setIsPTR] = React.useState(false)
|
||||||
|
|
||||||
const fullTag = React.useMemo(() => {
|
const fullTag = React.useMemo(() => {
|
||||||
|
@ -103,7 +102,7 @@ export default function HashtagScreen({
|
||||||
}, [isFetching, hasNextPage, error, fetchNextPage])
|
}, [isFetching, hasNextPage, error, fetchNextPage])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredView style={a.flex_1} sideBorders={gtMobile}>
|
<>
|
||||||
<ViewHeader
|
<ViewHeader
|
||||||
title={headerTitle}
|
title={headerTitle}
|
||||||
subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined}
|
subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined}
|
||||||
|
@ -157,8 +156,10 @@ export default function HashtagScreen({
|
||||||
onRetry={fetchNextPage}
|
onRetry={fetchNextPage}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
initialNumToRender={initialNumToRender}
|
||||||
|
windowSize={11}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CenteredView>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/
|
||||||
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
|
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
|
||||||
{
|
{
|
||||||
hideReplies: false,
|
hideReplies: false,
|
||||||
hideRepliesByUnfollowed: false,
|
hideRepliesByUnfollowed: true,
|
||||||
hideRepliesByLikeCount: 0,
|
hideRepliesByLikeCount: 0,
|
||||||
hideReposts: false,
|
hideReposts: false,
|
||||||
hideQuotePosts: false,
|
hideQuotePosts: false,
|
||||||
|
|
|
@ -169,7 +169,7 @@ export function usePreferencesSetBirthDateMutation() {
|
||||||
|
|
||||||
return useMutation<void, unknown, {birthDate: Date}>({
|
return useMutation<void, unknown, {birthDate: Date}>({
|
||||||
mutationFn: async ({birthDate}: {birthDate: Date}) => {
|
mutationFn: async ({birthDate}: {birthDate: Date}) => {
|
||||||
await getAgent().setPersonalDetails({birthDate})
|
await getAgent().setPersonalDetails({birthDate: birthDate.toISOString()})
|
||||||
// triggers a refetch
|
// triggers a refetch
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: preferencesQueryKey,
|
queryKey: preferencesQueryKey,
|
||||||
|
|
|
@ -32,6 +32,7 @@ import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
||||||
import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home'
|
import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home'
|
||||||
|
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||||
|
|
||||||
const LOADING_ITEM = {_reactKey: '__loading__'}
|
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||||
|
@ -84,6 +85,7 @@ let Feed = ({
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
const initialNumToRender = useInitialNumToRender()
|
||||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||||
const checkForNewRef = React.useRef<(() => void) | null>(null)
|
const checkForNewRef = React.useRef<(() => void) | null>(null)
|
||||||
const lastFetchRef = React.useRef<number>(Date.now())
|
const lastFetchRef = React.useRef<number>(Date.now())
|
||||||
|
@ -327,6 +329,8 @@ let Feed = ({
|
||||||
desktopFixedHeight={
|
desktopFixedHeight={
|
||||||
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
|
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
|
||||||
}
|
}
|
||||||
|
initialNumToRender={initialNumToRender}
|
||||||
|
windowSize={11}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
4
src/view/com/util/Views.d.ts
vendored
4
src/view/com/util/Views.d.ts
vendored
|
@ -5,4 +5,6 @@ export function CenteredView({
|
||||||
style,
|
style,
|
||||||
sideBorders,
|
sideBorders,
|
||||||
...props
|
...props
|
||||||
}: React.PropsWithChildren<ViewProps & {sideBorders?: boolean}>)
|
}: React.PropsWithChildren<
|
||||||
|
ViewProps & {sideBorders?: boolean; topBorder?: boolean}
|
||||||
|
>)
|
||||||
|
|
|
@ -32,8 +32,11 @@ interface AddedProps {
|
||||||
export function CenteredView({
|
export function CenteredView({
|
||||||
style,
|
style,
|
||||||
sideBorders,
|
sideBorders,
|
||||||
|
topBorder,
|
||||||
...props
|
...props
|
||||||
}: React.PropsWithChildren<ViewProps & {sideBorders?: boolean}>) {
|
}: React.PropsWithChildren<
|
||||||
|
ViewProps & {sideBorders?: boolean; topBorder?: boolean}
|
||||||
|
>) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
|
@ -46,6 +49,12 @@ export function CenteredView({
|
||||||
})
|
})
|
||||||
style = addStyle(style, pal.border)
|
style = addStyle(style, pal.border)
|
||||||
}
|
}
|
||||||
|
if (topBorder) {
|
||||||
|
style = addStyle(style, {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
})
|
||||||
|
style = addStyle(style, pal.border)
|
||||||
|
}
|
||||||
return <View style={style} {...props} />
|
return <View style={style} {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import React, {useState, useCallback} from 'react'
|
import React, {useState, useCallback} from 'react'
|
||||||
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
|
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
|
||||||
import DateTimePicker, {
|
|
||||||
DateTimePickerEvent,
|
|
||||||
} from '@react-native-community/datetimepicker'
|
|
||||||
import {
|
import {
|
||||||
FontAwesomeIcon,
|
FontAwesomeIcon,
|
||||||
FontAwesomeIconStyle,
|
FontAwesomeIconStyle,
|
||||||
|
@ -14,6 +11,7 @@ import {TypographyVariant} from 'lib/ThemeContext'
|
||||||
import {useTheme} from 'lib/ThemeContext'
|
import {useTheme} from 'lib/ThemeContext'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {getLocales} from 'expo-localization'
|
import {getLocales} from 'expo-localization'
|
||||||
|
import DatePicker from 'react-native-date-picker'
|
||||||
|
|
||||||
const LOCALE = getLocales()[0]
|
const LOCALE = getLocales()[0]
|
||||||
|
|
||||||
|
@ -43,11 +41,9 @@ export function DateInput(props: Props) {
|
||||||
}, [props.handleAsUTC])
|
}, [props.handleAsUTC])
|
||||||
|
|
||||||
const onChangeInternal = useCallback(
|
const onChangeInternal = useCallback(
|
||||||
(event: DateTimePickerEvent, date: Date | undefined) => {
|
(date: Date) => {
|
||||||
setShow(false)
|
setShow(false)
|
||||||
if (date) {
|
props.onChange(date)
|
||||||
props.onChange(date)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[setShow, props],
|
[setShow, props],
|
||||||
)
|
)
|
||||||
|
@ -56,6 +52,10 @@ export function DateInput(props: Props) {
|
||||||
setShow(true)
|
setShow(true)
|
||||||
}, [setShow])
|
}, [setShow])
|
||||||
|
|
||||||
|
const onCancel = useCallback(() => {
|
||||||
|
setShow(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{isAndroid && (
|
{isAndroid && (
|
||||||
|
@ -80,15 +80,17 @@ export function DateInput(props: Props) {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(isIOS || show) && (
|
{(isIOS || show) && (
|
||||||
<DateTimePicker
|
<DatePicker
|
||||||
testID={props.testID ? `${props.testID}-datepicker` : undefined}
|
timeZoneOffsetInMinutes={0}
|
||||||
|
modal={isAndroid}
|
||||||
|
open={isAndroid}
|
||||||
|
theme={theme.colorScheme}
|
||||||
|
date={props.value}
|
||||||
|
onDateChange={onChangeInternal}
|
||||||
|
onConfirm={onChangeInternal}
|
||||||
|
onCancel={onCancel}
|
||||||
mode="date"
|
mode="date"
|
||||||
timeZoneName={props.handleAsUTC ? 'Etc/UTC' : undefined}
|
testID={props.testID ? `${props.testID}-datepicker` : undefined}
|
||||||
display="spinner"
|
|
||||||
// @ts-ignore applies in iOS only -prf
|
|
||||||
themeVariant={theme.colorScheme}
|
|
||||||
value={props.value}
|
|
||||||
onChange={onChangeInternal}
|
|
||||||
accessibilityLabel={props.accessibilityLabel}
|
accessibilityLabel={props.accessibilityLabel}
|
||||||
accessibilityHint={props.accessibilityHint}
|
accessibilityHint={props.accessibilityHint}
|
||||||
accessibilityLabelledBy={props.accessibilityLabelledBy}
|
accessibilityLabelledBy={props.accessibilityLabelledBy}
|
||||||
|
|
|
@ -391,7 +391,7 @@ export function DesktopLeftNav() {
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="hand"
|
icon="hand"
|
||||||
style={pal.text as FontAwesomeIconStyle}
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
size={isDesktop ? 20 : 26}
|
size={isDesktop ? 23 : 26}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={_(msg`Moderation`)}
|
label={_(msg`Moderation`)}
|
||||||
|
|
20
yarn.lock
20
yarn.lock
|
@ -34,10 +34,10 @@
|
||||||
jsonpointer "^5.0.0"
|
jsonpointer "^5.0.0"
|
||||||
leven "^3.1.0"
|
leven "^3.1.0"
|
||||||
|
|
||||||
"@atproto/api@^0.10.4":
|
"@atproto/api@^0.10.5":
|
||||||
version "0.10.4"
|
version "0.10.5"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.4.tgz#b73446f2344783c42c6040082756449443f15750"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.5.tgz#e778e2843d08690df8df81f24028a7578e9b3cb4"
|
||||||
integrity sha512-9gwZt4v4pngfD4mgsET9i9Ym0PpMSzftTzqBjCbFpObx15zMkFemYnLUnyT/NEww2u/aRxjAe2TeBnU0dIbbuQ==
|
integrity sha512-GYdST5sPKU2JnPmm8x3KqjOSlDiYXrp4GkW7bpQTVLPabnUNq5NLN6HJEoJABjjOAsaLF12rBoV+JpRb1UjNsQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.2.3"
|
"@atproto/common-web" "^0.2.3"
|
||||||
"@atproto/lexicon" "^0.3.2"
|
"@atproto/lexicon" "^0.3.2"
|
||||||
|
@ -4986,13 +4986,6 @@
|
||||||
prompts "^2.4.2"
|
prompts "^2.4.2"
|
||||||
semver "^7.5.2"
|
semver "^7.5.2"
|
||||||
|
|
||||||
"@react-native-community/datetimepicker@7.6.1":
|
|
||||||
version "7.6.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/@react-native-community/datetimepicker/-/datetimepicker-7.6.1.tgz#98bdee01e3df490526ee1125e438c2030becac1f"
|
|
||||||
integrity sha512-g66Q2Kd9Uw3eRL7kkrTsGhi+eXxNoPDRFYH6z78sZQuYjPkUQgJDDMUYgBmaBsQx/fKMtemPrCj1ulGmyi0OSw==
|
|
||||||
dependencies:
|
|
||||||
invariant "^2.2.4"
|
|
||||||
|
|
||||||
"@react-native-community/eslint-config@^3.0.0":
|
"@react-native-community/eslint-config@^3.0.0":
|
||||||
version "3.2.0"
|
version "3.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/@react-native-community/eslint-config/-/eslint-config-3.2.0.tgz#42f677d5fff385bccf1be1d3b8faa8c086cf998d"
|
resolved "https://registry.yarnpkg.com/@react-native-community/eslint-config/-/eslint-config-3.2.0.tgz#42f677d5fff385bccf1be1d3b8faa8c086cf998d"
|
||||||
|
@ -18563,6 +18556,11 @@ react-native-appstate-hook@^1.0.6:
|
||||||
resolved "https://registry.yarnpkg.com/react-native-appstate-hook/-/react-native-appstate-hook-1.0.6.tgz#cbc16e7b89cfaea034cabd999f00e99053cabd06"
|
resolved "https://registry.yarnpkg.com/react-native-appstate-hook/-/react-native-appstate-hook-1.0.6.tgz#cbc16e7b89cfaea034cabd999f00e99053cabd06"
|
||||||
integrity sha512-0hPVyf5yLxCSVrrNEuGqN1ZnSSj3Ye2gZex0NtcK/AHYwMc0rXWFNZjBKOoZSouspqu3hXBbQ6NOUSTzrME1AQ==
|
integrity sha512-0hPVyf5yLxCSVrrNEuGqN1ZnSSj3Ye2gZex0NtcK/AHYwMc0rXWFNZjBKOoZSouspqu3hXBbQ6NOUSTzrME1AQ==
|
||||||
|
|
||||||
|
react-native-date-picker@^4.4.0:
|
||||||
|
version "4.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-native-date-picker/-/react-native-date-picker-4.4.0.tgz#fe5b6eb8d85a4a30b2991ada5169a30ce5023ead"
|
||||||
|
integrity sha512-Axx3byihwwhKRLRVjPAr/UaEysapkRcKmjjM8/05UaVm4Q0xDn2RFUcRdy1QAahhRcjLjlVYhepxvU5bdgy7ZQ==
|
||||||
|
|
||||||
react-native-dotenv@^3.3.1:
|
react-native-dotenv@^3.3.1:
|
||||||
version "3.4.9"
|
version "3.4.9"
|
||||||
resolved "https://registry.yarnpkg.com/react-native-dotenv/-/react-native-dotenv-3.4.9.tgz#621c5b0c1d0c5c7f569bfe5a1d804bec7885c010"
|
resolved "https://registry.yarnpkg.com/react-native-dotenv/-/react-native-dotenv-3.4.9.tgz#621c5b0c1d0c5c7f569bfe5a1d804bec7885c010"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue