Merge branch 'bluesky-social:main' into patch-4

This commit is contained in:
Minseo Lee
2024-01-21 14:34:18 +09:00
committed by GitHub
140 changed files with 31640 additions and 6755 deletions
+2 -1
View File
@@ -14,7 +14,8 @@ async function main() {
await server?.close()
console.log('Starting new server')
const inviteRequired = url?.query && 'invite' in url.query
server = await createServer({inviteRequired})
const phoneRequired = url?.query && 'phone' in url.query
server = await createServer({inviteRequired, phoneRequired})
console.log('Listening at', server.pdsUrl)
if (url?.query) {
if ('users' in url.query) {
+5 -7
View File
@@ -16,14 +16,12 @@ describe('Create account', () => {
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('otherServerBtn')).tap()
await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customServerInput')).clearText()
await element(by.id('customServerInput')).typeText(service)
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap()
await device.takeScreenshot('3- input test server URL')
await element(by.id('nextBtn')).tap()
await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details')
@@ -31,7 +29,7 @@ describe('Create account', () => {
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('4- entered handle')
await device.takeScreenshot('5- entered handle')
await element(by.id('nextBtn')).tap()
+4 -9
View File
@@ -1,10 +1,5 @@
/* eslint-env detox/detox */
/**
* This test is being skipped until we can resolve the detox crash issue
* with the side drawer.
*/
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
@@ -31,12 +26,12 @@ describe('invite-codes', () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('otherServerBtn')).tap()
await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customServerInput')).clearText()
await element(by.id('customServerInput')).typeText(service)
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap()
await device.takeScreenshot('3- input test server URL')
await element(by.id('nextBtn')).tap()
await element(by.id('inviteCodeInput')).typeText(inviteCode)
await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
@@ -0,0 +1,57 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
describe('invite-codes', () => {
let service: string
let inviteCode = ''
beforeAll(async () => {
service = await createServer('?users&invite&phone')
await openApp({permissions: {notifications: 'YES'}})
})
it('I can fetch invite codes', async () => {
await loginAsAlice()
await element(by.id('e2eOpenInviteCodesModal')).tap()
await expect(element(by.id('inviteCodesModal'))).toBeVisible()
const attrs = await element(by.id('inviteCode-0-code')).getAttributes()
inviteCode = attrs.text
await element(by.id('closeBtn')).tap()
await element(by.id('e2eSignOut')).tap()
})
it('I can create a new account with the invite code', async () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap()
await device.takeScreenshot('3- input test server URL')
await element(by.id('inviteCodeInput')).typeText(inviteCode)
await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap()
await element(by.id('phoneInput')).typeText('5558675309')
await element(by.id('requestCodeBtn')).tap()
await device.takeScreenshot('5- requested code')
await element(by.id('codeInput')).typeText('000000')
await device.takeScreenshot('6- entered code')
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('7- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
})
})
+85
View File
@@ -0,0 +1,85 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, createServer} from '../util'
describe('Create account', () => {
let service: string
beforeAll(async () => {
service = await createServer('?phone')
await openApp({permissions: {notifications: 'YES'}})
})
it('I can create a new account with text verification', async () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap()
await device.takeScreenshot('3- input test server URL')
await element(by.id('emailInput')).typeText('text-verification@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap()
await element(by.id('phoneInput')).typeText('1234567890')
await element(by.id('requestCodeBtn')).tap()
await device.takeScreenshot('5- requested code')
await element(by.id('codeInput')).typeText('000000')
await device.takeScreenshot('6- entered code')
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('text-verification-test')
await device.takeScreenshot('7- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
})
it('failed text verification correctly goes back to the code input screen', async () => {
await element(by.id('e2eSignOut')).tap()
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap()
await device.takeScreenshot('3- input test server URL')
await element(by.id('emailInput')).typeText('text-verification2@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap()
await element(by.id('phoneInput')).typeText('1234567890')
await element(by.id('requestCodeBtn')).tap()
await device.takeScreenshot('5- requested code')
await element(by.id('codeInput')).typeText('111111')
await device.takeScreenshot('6- entered code')
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('text-verification-test2')
await device.takeScreenshot('7- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('codeInput'))).toBeVisible()
await device.takeScreenshot('8- got error')
})
})
+1 -1
View File
@@ -105,7 +105,7 @@ async function openAppForDebugBuild(platform: string, opts: any) {
await sleep(3000)
}
export async function createServer(path = '') {
export async function createServer(path = ''): Promise<string> {
return new Promise(function (resolve, reject) {
var req = http.request(
{
+7 -7
View File
@@ -463,44 +463,44 @@ describe('parseEmbedPlayerFromUrl', () => {
type: 'youtube_video',
source: 'youtube',
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1&start=0',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1&start=0',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1&start=0',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1&start=0',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1&start=0',
},
{
type: 'youtube_short',
source: 'youtubeShorts',
hideDetails: true,
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1&start=0',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1&start=0',
},
undefined,
+1 -1
View File
@@ -25,7 +25,7 @@ module.exports = function () {
/**
* Android build number. Must be incremented for each release.
*/
const ANDROID_VERSION_CODE = 55
const ANDROID_VERSION_CODE = 58
/**
* Uses built-in Expo env vars
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V9h14v10H5ZM5 7h14V5H5v2Zm3 10.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM17.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 13.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 17.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 512 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 12c0-4.09 3.527-7.5 8-7.5s8 3.41 8 7.5c0 1.579-.419 2.056-.708 2.236-.388.241-1.031.286-2.058.153-.33-.043-.652-.096-.991-.152a65.905 65.905 0 0 0-.531-.087c-.52-.081-1.077-.156-1.61-.164-1.065-.016-2.336.245-2.996 1.567-.418.834-.295 1.67-.078 2.314.18.534.47 1.055.683 1.437v.001l.097.175.01.018C7.432 19.407 4 16.033 4 12Zm8-9.5C6.532 2.5 2 6.7 2 12s4.532 9.5 10 9.5c.401 0 .812-.04 1.166-.193.41-.176.761-.517.866-1.028.085-.416-.03-.796-.118-1.029a5.981 5.981 0 0 0-.351-.73l-.12-.215c-.215-.392-.403-.73-.52-1.078-.13-.387-.111-.614-.029-.78.146-.291.404-.473 1.178-.461.385.005.825.06 1.329.14.15.023.308.05.47.077.36.059.742.122 1.105.17 1.021.132 2.325.213 3.373-.439C21.496 15.22 22 13.874 22 12c0-5.3-4.532-9.5-10-9.5Zm3.5 8.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM9 12.25a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm1.5-2.75a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1011 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1005 B

+1 -1
View File
@@ -80,7 +80,7 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
}
}
af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, did.String(), "", "", 30)
af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, did.String(), "", "posts_no_replies", 30)
if err != nil {
log.Warn("failed to fetch author feed", "did", did, "err", err)
return err
+25 -6
View File
@@ -39,30 +39,49 @@
height: calc(100% + env(safe-area-inset-top));
}
/* Remove autofill styles on Webkit */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
border: 0;
-webkit-text-fill-color: transparent;
-webkit-box-shadow: none;
}
/* Force left-align date/time inputs on iOS mobile */
input::-webkit-date-and-time-value {
text-align: left;
}
/* Color theming */
:root {
--text: black;
--background: white;
--backgroundLight: #F3F3F8;
--backgroundLight: hsl(211, 20%, 95%);
}
html.colorMode--dark {
--text: white;
--background: black;
--backgroundLight: #26272D;
--background: hsl(211, 20%, 4%);
--backgroundLight: hsl(211, 20%, 20%);
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
html.colorMode--system {
--text: black;
--background: white;
--backgroundLight: #F3F3F8;
--backgroundLight: hsl(211, 20%, 95%);
}
}
@media (prefers-color-scheme: dark) {
html.colorMode--system {
--text: white;
--background: black;
--backgroundLight: #26272D;
--background: hsl(211, 20%, 4%);
--backgroundLight: hsl(211, 20%, 20%);
color-scheme: dark;
}
}
+35 -3
View File
@@ -1,7 +1,7 @@
import net from 'net'
import path from 'path'
import fs from 'fs'
import {TestNetwork} from '@atproto/dev-env'
import {TestNetwork, TestPds} from '@atproto/dev-env'
import {AtUri, BskyAgent} from '@atproto/api'
export interface TestUser {
@@ -55,19 +55,36 @@ class StringIdGenerator {
const ids = new StringIdGenerator()
export async function createServer(
{inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false},
{
inviteRequired,
phoneRequired,
}: {inviteRequired: boolean; phoneRequired: boolean} = {
inviteRequired: false,
phoneRequired: false,
},
): Promise<TestPDS> {
const port = await getPort()
const port = 3000
const port2 = await getPort(port + 1)
const port3 = await getPort(port2 + 1)
const pdsUrl = `http://localhost:${port}`
const id = ids.next()
const phoneParams = phoneRequired
? {
phoneVerificationRequired: true,
twilioAccountSid: 'ACXXXXXXX',
twilioAuthToken: 'AUTH',
twilioServiceSid: 'VAXXXXXXXX',
}
: {}
const testNet = await TestNetwork.create({
pds: {
port,
hostname: 'localhost',
dbPostgresSchema: `pds_${id}`,
inviteRequired,
...phoneParams,
},
bsky: {
dbPostgresSchema: `bsky_${id}`,
@@ -76,6 +93,7 @@ export async function createServer(
},
plc: {port: port2},
})
mockTwilio(testNet.pds)
const pic = fs.readFileSync(
path.join(__dirname, '..', 'assets', 'default-avatar.png'),
@@ -144,6 +162,8 @@ class Mocker {
email,
handle: name + '.test',
password: 'hunter2',
verificationPhone: '1234567890',
verificationCode: '000000',
})
await agent.upsertProfile(async () => {
const blob = await agent.uploadBlob(this.pic, {
@@ -430,3 +450,15 @@ async function getPort(start = 3000) {
}
throw new Error('Unable to find an available port')
}
export const mockTwilio = (pds: TestPds) => {
if (!pds.ctx.twilio) return
pds.ctx.twilio.sendCode = async (_number: string) => {
// do nothing
}
pds.ctx.twilio.verifyCode = async (_number: string, code: string) => {
return code === '000000'
}
}
+13 -1
View File
@@ -1,6 +1,18 @@
/** @type {import('@lingui/conf').LinguiConfig} */
module.exports = {
locales: ['en', 'de', 'es', 'fr', 'hi', 'id', 'ja', 'ko', 'pt-BR', 'uk'],
locales: [
'en',
'de',
'es',
'fr',
'hi',
'id',
'ja',
'ko',
'pt-BR',
'uk',
'ca',
],
catalogs: [
{
path: '<rootDir>/src/locale/locales/{locale}/messages',
+24 -22
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.63.0",
"version": "1.66.0",
"private": true,
"engines": {
"node": ">=18"
@@ -12,7 +12,7 @@
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp --verbose ./web-build/static/js/*.* ./bskyweb/static/js/",
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/",
"build-all": "yarn intl:build && eas build --platform all",
"start": "expo start --dev-client",
"start:prod": "expo start --dev-client --no-dev --minify",
@@ -39,7 +39,7 @@
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
},
"dependencies": {
"@atproto/api": "^0.8.0",
"@atproto/api": "^0.9.1",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1",
@@ -60,7 +60,7 @@
"@react-native-community/datetimepicker": "7.6.1",
"@react-native-masked-view/masked-view": "0.3.0",
"@react-native-menu/menu": "^0.8.0",
"@react-native-picker/picker": "2.5.1",
"@react-native-picker/picker": "2.6.1",
"@react-navigation/bottom-tabs": "^6.5.7",
"@react-navigation/drawer": "^6.6.2",
"@react-navigation/native": "^6.1.6",
@@ -70,6 +70,7 @@
"@segment/analytics-react-native": "^2.10.1",
"@segment/sovran-react-native": "^0.4.5",
"@sentry/react-native": "5.5.0",
"@tamagui/focus-scope": "^1.84.1",
"@tanstack/react-query": "^5.8.1",
"@tiptap/core": "^2.0.0-beta.220",
"@tiptap/extension-document": "^2.0.0-beta.220",
@@ -94,30 +95,32 @@
"email-validator": "^2.0.4",
"emoji-mart": "^5.5.2",
"eventemitter3": "^5.0.1",
"expo": "^50.0.0-preview.7",
"expo-application": "~5.8.1",
"expo": "^50.0.0-preview.10",
"expo-application": "~5.8.2",
"expo-build-properties": "^0.11.0",
"expo-camera": "~14.0.1",
"expo-constants": "~15.4.2",
"expo-dev-client": "~3.3.4",
"expo-device": "~5.9.1",
"expo-image": "~1.10.1",
"expo-constants": "~15.4.3",
"expo-dev-client": "~3.3.5",
"expo-device": "~5.9.2",
"expo-image": "~1.10.3",
"expo-image-manipulator": "^11.8.0",
"expo-image-picker": "~14.7.1",
"expo-localization": "~14.8.1",
"expo-localization": "~14.8.2",
"expo-media-library": "~15.9.1",
"expo-notifications": "~0.27.2",
"expo-notifications": "~0.27.3",
"expo-sharing": "^11.10.0",
"expo-splash-screen": "~0.26.1",
"expo-splash-screen": "~0.26.2",
"expo-status-bar": "~1.11.1",
"expo-system-ui": "~2.9.2",
"expo-system-ui": "~2.9.3",
"expo-task-manager": "~11.7.0",
"expo-updates": "~0.24.5",
"expo-updates": "~0.24.7",
"expo-web-browser": "~12.8.1",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
"libphonenumber-js": "^1.10.53",
"lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
@@ -140,7 +143,7 @@
"react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.0",
"react-native": "0.73.1",
"react-native": "0.73.2",
"react-native-appstate-hook": "^1.0.6",
"react-native-drawer-layout": "^4.0.0-alpha.3",
"react-native-fs": "^2.20.0",
@@ -148,24 +151,23 @@
"react-native-get-random-values": "~1.8.0",
"react-native-haptic-feedback": "^1.14.0",
"react-native-image-crop-picker": "^0.38.1",
"react-native-inappbrowser-reborn": "^3.6.3",
"react-native-ios-context-menu": "^1.15.3",
"react-native-linear-gradient": "^2.6.2",
"react-native-pager-view": "6.2.2",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-reanimated": "^3.6.0",
"react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "4.7.4",
"react-native-screens": "~3.27.0",
"react-native-svg": "14.0.0",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "~3.29.0",
"react-native-svg": "14.1.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.1",
"react-native-version-number": "^0.3.6",
"react-native-web": "~0.19.6",
"react-native-web-linear-gradient": "^1.1.2",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "^13.6.3",
"react-native-webview": "13.6.4",
"react-responsive": "^9.0.2",
"rn-fetch-blob": "^0.12.0",
"sentry-expo": "~7.0.1",
+10
View File
@@ -0,0 +1,10 @@
diff --git a/node_modules/@lingui/core/dist/index.mjs b/node_modules/@lingui/core/dist/index.mjs
index 9759736..881f67b 100644
--- a/node_modules/@lingui/core/dist/index.mjs
+++ b/node_modules/@lingui/core/dist/index.mjs
@@ -1,4 +1,4 @@
-import unraw from 'unraw';
+import { unraw } from 'unraw';
import { compileMessage } from '@lingui/message-utils/compileMessage';
const isString = (s) => typeof s === "string";
-12
View File
@@ -1,12 +0,0 @@
diff --git a/node_modules/babel-preset-fbjs/plugins/inline-requires.js b/node_modules/babel-preset-fbjs/plugins/inline-requires.js
index b11fc83..e18661a 100644
--- a/node_modules/babel-preset-fbjs/plugins/inline-requires.js
+++ b/node_modules/babel-preset-fbjs/plugins/inline-requires.js
@@ -256,6 +256,7 @@ function getInlineableModule(path, state) {
return moduleName == null ||
state.ignoredRequires.has(moduleName) ||
+ moduleName.startsWith('@babel/runtime/') ||
isRequireInScope
? null
: { moduleName, requireFnName: fnName };
@@ -1,5 +1,5 @@
diff --git a/node_modules/metro-transform-worker/src/index.js b/node_modules/metro-transform-worker/src/index.js
index cae11e7..42f251b 100644
index 9f2e3d2..5222c8e 100644
--- a/node_modules/metro-transform-worker/src/index.js
+++ b/node_modules/metro-transform-worker/src/index.js
@@ -189,6 +189,10 @@ async function transformJS(file, { config, options, projectRoot }) {
+35 -24
View File
@@ -13,6 +13,8 @@ import {
import 'view/icons'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {init as initPersistedState} from '#/state/persisted'
import {listenSessionDropped} from './state/events'
import {useColorMode} from 'state/shell'
@@ -25,6 +27,7 @@ import {queryClient} from 'lib/react-query'
import {TestCtrls} from 'view/com/testing/TestCtrls'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as DialogStateProvider} from 'state/dialogs'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
@@ -39,6 +42,7 @@ import {
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {Splash} from '#/Splash'
import {Provider as PortalProvider} from '#/components/Portal'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -48,6 +52,7 @@ function InnerApp() {
const colorMode = useColorMode()
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme(colorMode)
const {_} = useLingui()
// init
@@ -63,25 +68,27 @@ function InnerApp() {
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Splash isReady={!isInitialLoad}>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
</LoggedOutViewProvider>
</React.Fragment>
</Splash>
<Alf theme={theme}>
<Splash isReady={!isInitialLoad}>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
</LoggedOutViewProvider>
</React.Fragment>
</Splash>
</Alf>
</SafeAreaProvider>
)
}
@@ -109,11 +116,15 @@ function App() {
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<I18nProvider>
<InnerApp />
</I18nProvider>
</LightboxStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
+12 -6
View File
@@ -8,6 +8,7 @@ import {RootSiblingParent} from 'react-native-root-siblings'
import 'view/icons'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell'
import {Shell} from 'view/shell/index'
@@ -16,6 +17,7 @@ import {ThemeProvider} from 'lib/ThemeContext'
import {queryClient} from 'lib/react-query'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as DialogStateProvider} from 'state/dialogs'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
@@ -29,7 +31,7 @@ import {
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
function InnerApp() {
const {isInitialLoad, currentAccount} = useSession()
@@ -92,11 +94,15 @@ function App() {
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<I18nProvider>
<InnerApp />
</I18nProvider>
</LightboxStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
+15 -26
View File
@@ -1,5 +1,4 @@
import * as React from 'react'
import {StyleSheet} from 'react-native'
import {
NavigationContainer,
createNavigationContainerRef,
@@ -25,7 +24,6 @@ import {
import {BottomBar} from './view/shell/bottom-bar/BottomBar'
import {buildStateObject} from 'lib/routes/helpers'
import {State, RouteParams} from 'lib/routes/types'
import {colors} from 'lib/styles'
import {isAndroid, isNative} from 'platform/detection'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {router} from './routes'
@@ -61,7 +59,7 @@ import {ProfileListScreen} from './view/screens/ProfileList'
import {PostThreadScreen} from './view/screens/PostThread'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {DebugScreen} from './view/screens/DebugNew'
import {Storybook} from './view/screens/Storybook'
import {LogScreen} from './view/screens/Log'
import {SupportScreen} from './view/screens/Support'
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
@@ -144,7 +142,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
name="Profile"
getComponent={() => ProfileScreen}
options={({route}) => ({
title: title(msg`@${route.params.name}`),
title: bskyTitle(`@${route.params.name}`, unreadCountLabel),
animation: 'none',
})}
/>
@@ -200,8 +198,8 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
/>
<Stack.Screen
name="Debug"
getComponent={() => DebugScreen}
options={{title: title(msg`Debug`), requireAuth: true}}
getComponent={() => Storybook}
options={{title: title(msg`Storybook`), requireAuth: true}}
/>
<Stack.Screen
name="Log"
@@ -299,7 +297,7 @@ function TabsNavigator() {
}
function HomeTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
const pal = usePalette('default')
return (
<HomeTab.Navigator
@@ -309,7 +307,7 @@ function HomeTabNavigator() {
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle,
contentStyle: pal.view,
}}>
<HomeTab.Screen
name="Home"
@@ -322,7 +320,7 @@ function HomeTabNavigator() {
}
function SearchTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
const pal = usePalette('default')
return (
<SearchTab.Navigator
screenOptions={{
@@ -331,7 +329,7 @@ function SearchTabNavigator() {
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle,
contentStyle: pal.view,
}}>
<SearchTab.Screen name="Search" getComponent={() => SearchScreen} />
{commonScreens(SearchTab as typeof HomeTab)}
@@ -340,7 +338,7 @@ function SearchTabNavigator() {
}
function FeedsTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
const pal = usePalette('default')
return (
<FeedsTab.Navigator
screenOptions={{
@@ -349,7 +347,7 @@ function FeedsTabNavigator() {
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle,
contentStyle: pal.view,
}}>
<FeedsTab.Screen
name="Feeds"
@@ -362,7 +360,7 @@ function FeedsTabNavigator() {
}
function NotificationsTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
const pal = usePalette('default')
return (
<NotificationsTab.Navigator
screenOptions={{
@@ -371,7 +369,7 @@ function NotificationsTabNavigator() {
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle,
contentStyle: pal.view,
}}>
<NotificationsTab.Screen
name="Notifications"
@@ -384,7 +382,7 @@ function NotificationsTabNavigator() {
}
function MyProfileTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
const pal = usePalette('default')
return (
<MyProfileTab.Navigator
screenOptions={{
@@ -393,7 +391,7 @@ function MyProfileTabNavigator() {
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle,
contentStyle: pal.view,
}}>
<MyProfileTab.Screen
// @ts-ignore // TODO: fix this broken type in ProfileScreen
@@ -424,7 +422,7 @@ const FlatNavigator = () => {
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle: [pal.view],
contentStyle: pal.view,
}}>
<Flat.Screen
name="Home"
@@ -620,15 +618,6 @@ function handleLink(url: string) {
}
}
const styles = StyleSheet.create({
bgDark: {
backgroundColor: colors.black,
},
bgLight: {
backgroundColor: colors.white,
},
})
let didInit = false
function logModuleInitTime() {
if (didInit) {
+266 -78
View File
@@ -4,6 +4,9 @@ export const atoms = {
/*
* Positioning
*/
fixed: {
position: 'fixed',
},
absolute: {
position: 'absolute',
},
@@ -32,6 +35,10 @@ export const atoms = {
zIndex: 50,
},
overflow_hidden: {
overflow: 'hidden',
},
/*
* Width
*/
@@ -45,6 +52,12 @@ export const atoms = {
/*
* Border radius
*/
rounded_2xs: {
borderRadius: tokens.borderRadius._2xs,
},
rounded_xs: {
borderRadius: tokens.borderRadius.xs,
},
rounded_sm: {
borderRadius: tokens.borderRadius.sm,
},
@@ -58,8 +71,8 @@ export const atoms = {
/*
* Flex
*/
gap_xxs: {
gap: tokens.space.xxs,
gap_2xs: {
gap: tokens.space._2xs,
},
gap_xs: {
gap: tokens.space.xs,
@@ -76,8 +89,17 @@ export const atoms = {
gap_xl: {
gap: tokens.space.xl,
},
gap_xxl: {
gap: tokens.space.xxl,
gap_2xl: {
gap: tokens.space._2xl,
},
gap_3xl: {
gap: tokens.space._3xl,
},
gap_4xl: {
gap: tokens.space._4xl,
},
gap_5xl: {
gap: tokens.space._5xl,
},
flex: {
display: 'flex',
@@ -125,9 +147,9 @@ export const atoms = {
text_right: {
textAlign: 'right',
},
text_xxs: {
fontSize: tokens.fontSize.xxs,
lineHeight: tokens.fontSize.xxs,
text_2xs: {
fontSize: tokens.fontSize._2xs,
lineHeight: tokens.fontSize._2xs,
},
text_xs: {
fontSize: tokens.fontSize.xs,
@@ -149,9 +171,21 @@ export const atoms = {
fontSize: tokens.fontSize.xl,
lineHeight: tokens.fontSize.xl,
},
text_xxl: {
fontSize: tokens.fontSize.xxl,
lineHeight: tokens.fontSize.xxl,
text_2xl: {
fontSize: tokens.fontSize._2xl,
lineHeight: tokens.fontSize._2xl,
},
text_3xl: {
fontSize: tokens.fontSize._3xl,
lineHeight: tokens.fontSize._3xl,
},
text_4xl: {
fontSize: tokens.fontSize._4xl,
lineHeight: tokens.fontSize._4xl,
},
text_5xl: {
fontSize: tokens.fontSize._5xl,
lineHeight: tokens.fontSize._5xl,
},
leading_tight: {
lineHeight: 1.25,
@@ -162,11 +196,8 @@ export const atoms = {
font_normal: {
fontWeight: tokens.fontWeight.normal,
},
font_semibold: {
fontWeight: tokens.fontWeight.semibold,
},
font_bold: {
fontWeight: tokens.fontWeight.bold,
fontWeight: tokens.fontWeight.semibold,
},
/*
@@ -182,11 +213,30 @@ export const atoms = {
borderBottomWidth: 1,
},
/*
* Shadow
*/
shadow_sm: {
shadowRadius: 8,
shadowOpacity: 0.1,
elevation: 8,
},
shadow_md: {
shadowRadius: 16,
shadowOpacity: 0.1,
elevation: 16,
},
shadow_lg: {
shadowRadius: 32,
shadowOpacity: 0.1,
elevation: 24,
},
/*
* Padding
*/
p_xxs: {
padding: tokens.space.xxs,
p_2xs: {
padding: tokens.space._2xs,
},
p_xs: {
padding: tokens.space.xs,
@@ -203,12 +253,21 @@ export const atoms = {
p_xl: {
padding: tokens.space.xl,
},
p_xxl: {
padding: tokens.space.xxl,
p_2xl: {
padding: tokens.space._2xl,
},
px_xxs: {
paddingLeft: tokens.space.xxs,
paddingRight: tokens.space.xxs,
p_3xl: {
padding: tokens.space._3xl,
},
p_4xl: {
padding: tokens.space._4xl,
},
p_5xl: {
padding: tokens.space._5xl,
},
px_2xs: {
paddingLeft: tokens.space._2xs,
paddingRight: tokens.space._2xs,
},
px_xs: {
paddingLeft: tokens.space.xs,
@@ -230,13 +289,25 @@ export const atoms = {
paddingLeft: tokens.space.xl,
paddingRight: tokens.space.xl,
},
px_xxl: {
paddingLeft: tokens.space.xxl,
paddingRight: tokens.space.xxl,
px_2xl: {
paddingLeft: tokens.space._2xl,
paddingRight: tokens.space._2xl,
},
py_xxs: {
paddingTop: tokens.space.xxs,
paddingBottom: tokens.space.xxs,
px_3xl: {
paddingLeft: tokens.space._3xl,
paddingRight: tokens.space._3xl,
},
px_4xl: {
paddingLeft: tokens.space._4xl,
paddingRight: tokens.space._4xl,
},
px_5xl: {
paddingLeft: tokens.space._5xl,
paddingRight: tokens.space._5xl,
},
py_2xs: {
paddingTop: tokens.space._2xs,
paddingBottom: tokens.space._2xs,
},
py_xs: {
paddingTop: tokens.space.xs,
@@ -258,12 +329,24 @@ export const atoms = {
paddingTop: tokens.space.xl,
paddingBottom: tokens.space.xl,
},
py_xxl: {
paddingTop: tokens.space.xxl,
paddingBottom: tokens.space.xxl,
py_2xl: {
paddingTop: tokens.space._2xl,
paddingBottom: tokens.space._2xl,
},
pt_xxs: {
paddingTop: tokens.space.xxs,
py_3xl: {
paddingTop: tokens.space._3xl,
paddingBottom: tokens.space._3xl,
},
py_4xl: {
paddingTop: tokens.space._4xl,
paddingBottom: tokens.space._4xl,
},
py_5xl: {
paddingTop: tokens.space._5xl,
paddingBottom: tokens.space._5xl,
},
pt_2xs: {
paddingTop: tokens.space._2xs,
},
pt_xs: {
paddingTop: tokens.space.xs,
@@ -280,11 +363,20 @@ export const atoms = {
pt_xl: {
paddingTop: tokens.space.xl,
},
pt_xxl: {
paddingTop: tokens.space.xxl,
pt_2xl: {
paddingTop: tokens.space._2xl,
},
pb_xxs: {
paddingBottom: tokens.space.xxs,
pt_3xl: {
paddingTop: tokens.space._3xl,
},
pt_4xl: {
paddingTop: tokens.space._4xl,
},
pt_5xl: {
paddingTop: tokens.space._5xl,
},
pb_2xs: {
paddingBottom: tokens.space._2xs,
},
pb_xs: {
paddingBottom: tokens.space.xs,
@@ -301,11 +393,20 @@ export const atoms = {
pb_xl: {
paddingBottom: tokens.space.xl,
},
pb_xxl: {
paddingBottom: tokens.space.xxl,
pb_2xl: {
paddingBottom: tokens.space._2xl,
},
pl_xxs: {
paddingLeft: tokens.space.xxs,
pb_3xl: {
paddingBottom: tokens.space._3xl,
},
pb_4xl: {
paddingBottom: tokens.space._4xl,
},
pb_5xl: {
paddingBottom: tokens.space._5xl,
},
pl_2xs: {
paddingLeft: tokens.space._2xs,
},
pl_xs: {
paddingLeft: tokens.space.xs,
@@ -322,11 +423,20 @@ export const atoms = {
pl_xl: {
paddingLeft: tokens.space.xl,
},
pl_xxl: {
paddingLeft: tokens.space.xxl,
pl_2xl: {
paddingLeft: tokens.space._2xl,
},
pr_xxs: {
paddingRight: tokens.space.xxs,
pl_3xl: {
paddingLeft: tokens.space._3xl,
},
pl_4xl: {
paddingLeft: tokens.space._4xl,
},
pl_5xl: {
paddingLeft: tokens.space._5xl,
},
pr_2xs: {
paddingRight: tokens.space._2xs,
},
pr_xs: {
paddingRight: tokens.space.xs,
@@ -343,15 +453,24 @@ export const atoms = {
pr_xl: {
paddingRight: tokens.space.xl,
},
pr_xxl: {
paddingRight: tokens.space.xxl,
pr_2xl: {
paddingRight: tokens.space._2xl,
},
pr_3xl: {
paddingRight: tokens.space._3xl,
},
pr_4xl: {
paddingRight: tokens.space._4xl,
},
pr_5xl: {
paddingRight: tokens.space._5xl,
},
/*
* Margin
*/
m_xxs: {
margin: tokens.space.xxs,
m_2xs: {
margin: tokens.space._2xs,
},
m_xs: {
margin: tokens.space.xs,
@@ -368,12 +487,21 @@ export const atoms = {
m_xl: {
margin: tokens.space.xl,
},
m_xxl: {
margin: tokens.space.xxl,
m_2xl: {
margin: tokens.space._2xl,
},
mx_xxs: {
marginLeft: tokens.space.xxs,
marginRight: tokens.space.xxs,
m_3xl: {
margin: tokens.space._3xl,
},
m_4xl: {
margin: tokens.space._4xl,
},
m_5xl: {
margin: tokens.space._5xl,
},
mx_2xs: {
marginLeft: tokens.space._2xs,
marginRight: tokens.space._2xs,
},
mx_xs: {
marginLeft: tokens.space.xs,
@@ -395,13 +523,25 @@ export const atoms = {
marginLeft: tokens.space.xl,
marginRight: tokens.space.xl,
},
mx_xxl: {
marginLeft: tokens.space.xxl,
marginRight: tokens.space.xxl,
mx_2xl: {
marginLeft: tokens.space._2xl,
marginRight: tokens.space._2xl,
},
my_xxs: {
marginTop: tokens.space.xxs,
marginBottom: tokens.space.xxs,
mx_3xl: {
marginLeft: tokens.space._3xl,
marginRight: tokens.space._3xl,
},
mx_4xl: {
marginLeft: tokens.space._4xl,
marginRight: tokens.space._4xl,
},
mx_5xl: {
marginLeft: tokens.space._5xl,
marginRight: tokens.space._5xl,
},
my_2xs: {
marginTop: tokens.space._2xs,
marginBottom: tokens.space._2xs,
},
my_xs: {
marginTop: tokens.space.xs,
@@ -423,12 +563,24 @@ export const atoms = {
marginTop: tokens.space.xl,
marginBottom: tokens.space.xl,
},
my_xxl: {
marginTop: tokens.space.xxl,
marginBottom: tokens.space.xxl,
my_2xl: {
marginTop: tokens.space._2xl,
marginBottom: tokens.space._2xl,
},
mt_xxs: {
marginTop: tokens.space.xxs,
my_3xl: {
marginTop: tokens.space._3xl,
marginBottom: tokens.space._3xl,
},
my_4xl: {
marginTop: tokens.space._4xl,
marginBottom: tokens.space._4xl,
},
my_5xl: {
marginTop: tokens.space._5xl,
marginBottom: tokens.space._5xl,
},
mt_2xs: {
marginTop: tokens.space._2xs,
},
mt_xs: {
marginTop: tokens.space.xs,
@@ -445,11 +597,20 @@ export const atoms = {
mt_xl: {
marginTop: tokens.space.xl,
},
mt_xxl: {
marginTop: tokens.space.xxl,
mt_2xl: {
marginTop: tokens.space._2xl,
},
mb_xxs: {
marginBottom: tokens.space.xxs,
mt_3xl: {
marginTop: tokens.space._3xl,
},
mt_4xl: {
marginTop: tokens.space._4xl,
},
mt_5xl: {
marginTop: tokens.space._5xl,
},
mb_2xs: {
marginBottom: tokens.space._2xs,
},
mb_xs: {
marginBottom: tokens.space.xs,
@@ -466,11 +627,20 @@ export const atoms = {
mb_xl: {
marginBottom: tokens.space.xl,
},
mb_xxl: {
marginBottom: tokens.space.xxl,
mb_2xl: {
marginBottom: tokens.space._2xl,
},
ml_xxs: {
marginLeft: tokens.space.xxs,
mb_3xl: {
marginBottom: tokens.space._3xl,
},
mb_4xl: {
marginBottom: tokens.space._4xl,
},
mb_5xl: {
marginBottom: tokens.space._5xl,
},
ml_2xs: {
marginLeft: tokens.space._2xs,
},
ml_xs: {
marginLeft: tokens.space.xs,
@@ -487,11 +657,20 @@ export const atoms = {
ml_xl: {
marginLeft: tokens.space.xl,
},
ml_xxl: {
marginLeft: tokens.space.xxl,
ml_2xl: {
marginLeft: tokens.space._2xl,
},
mr_xxs: {
marginRight: tokens.space.xxs,
ml_3xl: {
marginLeft: tokens.space._3xl,
},
ml_4xl: {
marginLeft: tokens.space._4xl,
},
ml_5xl: {
marginLeft: tokens.space._5xl,
},
mr_2xs: {
marginRight: tokens.space._2xs,
},
mr_xs: {
marginRight: tokens.space.xs,
@@ -508,7 +687,16 @@ export const atoms = {
mr_xl: {
marginRight: tokens.space.xl,
},
mr_xxl: {
marginRight: tokens.space.xxl,
mr_2xl: {
marginRight: tokens.space._2xl,
},
mr_3xl: {
marginRight: tokens.space._3xl,
},
mr_4xl: {
marginRight: tokens.space._4xl,
},
mr_5xl: {
marginRight: tokens.space._5xl,
},
} as const
+1
View File
@@ -5,6 +5,7 @@ import * as themes from '#/alf/themes'
export * as tokens from '#/alf/tokens'
export {atoms} from '#/alf/atoms'
export * from '#/alf/util/platform'
export * from '#/alf/util/flatten'
type BreakpointName = keyof typeof breakpoints
+259 -47
View File
@@ -1,108 +1,320 @@
import * as tokens from '#/alf/tokens'
import type {Mutable} from '#/alf/types'
import {atoms} from '#/alf/atoms'
export type ThemeName = 'light' | 'dark'
export type ThemeName = 'light' | 'dim' | 'dark'
export type ReadonlyTheme = typeof light
export type Theme = Mutable<ReadonlyTheme>
export type ReadonlyPalette = typeof lightPalette
export type Palette = Mutable<ReadonlyPalette>
export type Palette = {
primary: string
positive: string
negative: string
}
export const lightPalette = {
white: tokens.color.gray_0,
black: tokens.color.gray_1000,
export const lightPalette: Palette = {
primary: tokens.color.blue_500,
positive: tokens.color.green_500,
negative: tokens.color.red_500,
contrast_25: tokens.color.gray_25,
contrast_50: tokens.color.gray_50,
contrast_100: tokens.color.gray_100,
contrast_200: tokens.color.gray_200,
contrast_300: tokens.color.gray_300,
contrast_400: tokens.color.gray_400,
contrast_500: tokens.color.gray_500,
contrast_600: tokens.color.gray_600,
contrast_700: tokens.color.gray_700,
contrast_800: tokens.color.gray_800,
contrast_900: tokens.color.gray_900,
contrast_950: tokens.color.gray_950,
contrast_975: tokens.color.gray_975,
primary_25: tokens.color.blue_25,
primary_50: tokens.color.blue_50,
primary_100: tokens.color.blue_100,
primary_200: tokens.color.blue_200,
primary_300: tokens.color.blue_300,
primary_400: tokens.color.blue_400,
primary_500: tokens.color.blue_500,
primary_600: tokens.color.blue_600,
primary_700: tokens.color.blue_700,
primary_800: tokens.color.blue_800,
primary_900: tokens.color.blue_900,
primary_950: tokens.color.blue_950,
primary_975: tokens.color.blue_975,
positive_25: tokens.color.green_25,
positive_50: tokens.color.green_50,
positive_100: tokens.color.green_100,
positive_200: tokens.color.green_200,
positive_300: tokens.color.green_300,
positive_400: tokens.color.green_400,
positive_500: tokens.color.green_500,
positive_600: tokens.color.green_600,
positive_700: tokens.color.green_700,
positive_800: tokens.color.green_800,
positive_900: tokens.color.green_900,
positive_950: tokens.color.green_950,
positive_975: tokens.color.green_975,
negative_25: tokens.color.red_25,
negative_50: tokens.color.red_50,
negative_100: tokens.color.red_100,
negative_200: tokens.color.red_200,
negative_300: tokens.color.red_300,
negative_400: tokens.color.red_400,
negative_500: tokens.color.red_500,
negative_600: tokens.color.red_600,
negative_700: tokens.color.red_700,
negative_800: tokens.color.red_800,
negative_900: tokens.color.red_900,
negative_950: tokens.color.red_950,
negative_975: tokens.color.red_975,
} as const
export const darkPalette: Palette = {
primary: tokens.color.blue_500,
positive: tokens.color.green_400,
negative: tokens.color.red_400,
white: tokens.color.gray_0,
black: tokens.color.gray_1000,
contrast_25: tokens.color.gray_975,
contrast_50: tokens.color.gray_950,
contrast_100: tokens.color.gray_900,
contrast_200: tokens.color.gray_800,
contrast_300: tokens.color.gray_700,
contrast_400: tokens.color.gray_600,
contrast_500: tokens.color.gray_500,
contrast_600: tokens.color.gray_400,
contrast_700: tokens.color.gray_300,
contrast_800: tokens.color.gray_200,
contrast_900: tokens.color.gray_100,
contrast_950: tokens.color.gray_50,
contrast_975: tokens.color.gray_25,
primary_25: tokens.color.blue_25,
primary_50: tokens.color.blue_50,
primary_100: tokens.color.blue_100,
primary_200: tokens.color.blue_200,
primary_300: tokens.color.blue_300,
primary_400: tokens.color.blue_400,
primary_500: tokens.color.blue_500,
primary_600: tokens.color.blue_600,
primary_700: tokens.color.blue_700,
primary_800: tokens.color.blue_800,
primary_900: tokens.color.blue_900,
primary_950: tokens.color.blue_950,
primary_975: tokens.color.blue_975,
positive_25: tokens.color.green_25,
positive_50: tokens.color.green_50,
positive_100: tokens.color.green_100,
positive_200: tokens.color.green_200,
positive_300: tokens.color.green_300,
positive_400: tokens.color.green_400,
positive_500: tokens.color.green_500,
positive_600: tokens.color.green_600,
positive_700: tokens.color.green_700,
positive_800: tokens.color.green_800,
positive_900: tokens.color.green_900,
positive_950: tokens.color.green_950,
positive_975: tokens.color.green_975,
negative_25: tokens.color.red_25,
negative_50: tokens.color.red_50,
negative_100: tokens.color.red_100,
negative_200: tokens.color.red_200,
negative_300: tokens.color.red_300,
negative_400: tokens.color.red_400,
negative_500: tokens.color.red_500,
negative_600: tokens.color.red_600,
negative_700: tokens.color.red_700,
negative_800: tokens.color.red_800,
negative_900: tokens.color.red_900,
negative_950: tokens.color.red_950,
negative_975: tokens.color.red_975,
} as const
export const light = {
name: 'light',
palette: lightPalette,
atoms: {
text: {
color: tokens.color.gray_1000,
color: lightPalette.black,
},
text_contrast_700: {
color: tokens.color.gray_700,
color: lightPalette.contrast_700,
},
text_contrast_600: {
color: lightPalette.contrast_600,
},
text_contrast_500: {
color: tokens.color.gray_500,
color: lightPalette.contrast_500,
},
text_contrast_400: {
color: lightPalette.contrast_400,
},
text_inverted: {
color: tokens.color.white,
color: lightPalette.white,
},
bg: {
backgroundColor: tokens.color.white,
backgroundColor: lightPalette.white,
},
bg_contrast_25: {
backgroundColor: lightPalette.contrast_25,
},
bg_contrast_50: {
backgroundColor: lightPalette.contrast_50,
},
bg_contrast_100: {
backgroundColor: tokens.color.gray_100,
backgroundColor: lightPalette.contrast_100,
},
bg_contrast_200: {
backgroundColor: tokens.color.gray_200,
backgroundColor: lightPalette.contrast_200,
},
bg_contrast_300: {
backgroundColor: tokens.color.gray_300,
},
bg_positive: {
backgroundColor: tokens.color.green_500,
},
bg_negative: {
backgroundColor: tokens.color.red_400,
backgroundColor: lightPalette.contrast_300,
},
border: {
borderColor: tokens.color.gray_200,
borderColor: lightPalette.contrast_100,
},
border_contrast_500: {
borderColor: tokens.color.gray_500,
border_contrast: {
borderColor: lightPalette.contrast_400,
},
shadow_sm: {
...atoms.shadow_sm,
shadowColor: lightPalette.black,
},
shadow_md: {
...atoms.shadow_md,
shadowColor: lightPalette.black,
},
shadow_lg: {
...atoms.shadow_lg,
shadowColor: lightPalette.black,
},
},
}
export const dim: Theme = {
name: 'dim',
palette: darkPalette,
atoms: {
text: {
color: darkPalette.white,
},
text_contrast_700: {
color: darkPalette.contrast_800,
},
text_contrast_600: {
color: darkPalette.contrast_700,
},
text_contrast_500: {
color: darkPalette.contrast_600,
},
text_contrast_400: {
color: darkPalette.contrast_500,
},
text_inverted: {
color: darkPalette.black,
},
bg: {
backgroundColor: darkPalette.contrast_50,
},
bg_contrast_25: {
backgroundColor: darkPalette.contrast_100,
},
bg_contrast_50: {
backgroundColor: darkPalette.contrast_200,
},
bg_contrast_100: {
backgroundColor: darkPalette.contrast_300,
},
bg_contrast_200: {
backgroundColor: darkPalette.contrast_400,
},
bg_contrast_300: {
backgroundColor: darkPalette.contrast_500,
},
border: {
borderColor: darkPalette.contrast_200,
},
border_contrast: {
borderColor: darkPalette.contrast_400,
},
shadow_sm: {
...atoms.shadow_sm,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
shadow_md: {
...atoms.shadow_md,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
shadow_lg: {
...atoms.shadow_lg,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
},
}
export const dark: Theme = {
name: 'dark',
palette: darkPalette,
atoms: {
text: {
color: tokens.color.white,
color: darkPalette.white,
},
text_contrast_700: {
color: tokens.color.gray_300,
color: darkPalette.contrast_700,
},
text_contrast_600: {
color: darkPalette.contrast_600,
},
text_contrast_500: {
color: tokens.color.gray_500,
color: darkPalette.contrast_500,
},
text_contrast_400: {
color: darkPalette.contrast_400,
},
text_inverted: {
color: tokens.color.gray_1000,
color: darkPalette.black,
},
bg: {
backgroundColor: tokens.color.gray_1000,
backgroundColor: darkPalette.black,
},
bg_contrast_25: {
backgroundColor: darkPalette.contrast_50,
},
bg_contrast_50: {
backgroundColor: darkPalette.contrast_100,
},
bg_contrast_100: {
backgroundColor: tokens.color.gray_900,
backgroundColor: darkPalette.contrast_200,
},
bg_contrast_200: {
backgroundColor: tokens.color.gray_800,
backgroundColor: darkPalette.contrast_300,
},
bg_contrast_300: {
backgroundColor: tokens.color.gray_700,
},
bg_positive: {
backgroundColor: tokens.color.green_400,
},
bg_negative: {
backgroundColor: tokens.color.red_400,
backgroundColor: darkPalette.contrast_400,
},
border: {
borderColor: tokens.color.gray_800,
borderColor: darkPalette.contrast_100,
},
border_contrast_500: {
borderColor: tokens.color.gray_500,
border_contrast: {
borderColor: darkPalette.contrast_300,
},
shadow_sm: {
...atoms.shadow_sm,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
shadow_md: {
...atoms.shadow_md,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
shadow_lg: {
...atoms.shadow_lg,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
},
}
+121 -53
View File
@@ -1,79 +1,95 @@
const BLUE_HUE = 211
const GRAYSCALE_SATURATION = 22
const RED_HUE = 346
const GREEN_HUE = 152
export const color = {
white: '#FFFFFF',
trueBlack: '#000000',
gray_0: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 100%)`,
gray_100: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 95%)`,
gray_200: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 85%)`,
gray_300: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 75%)`,
gray_400: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 65%)`,
gray_500: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 55%)`,
gray_600: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 45%)`,
gray_700: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 35%)`,
gray_800: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 25%)`,
gray_900: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 15%)`,
gray_1000: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 5%)`,
gray_0: `hsl(${BLUE_HUE}, 20%, 100%)`,
gray_25: `hsl(${BLUE_HUE}, 20%, 97%)`,
gray_50: `hsl(${BLUE_HUE}, 20%, 95%)`,
gray_100: `hsl(${BLUE_HUE}, 20%, 90%)`,
gray_200: `hsl(${BLUE_HUE}, 20%, 80%)`,
gray_300: `hsl(${BLUE_HUE}, 20%, 70%)`,
gray_400: `hsl(${BLUE_HUE}, 20%, 60%)`,
gray_500: `hsl(${BLUE_HUE}, 20%, 50%)`,
gray_600: `hsl(${BLUE_HUE}, 20%, 42%)`,
gray_700: `hsl(${BLUE_HUE}, 20%, 34%)`,
gray_800: `hsl(${BLUE_HUE}, 20%, 26%)`,
gray_900: `hsl(${BLUE_HUE}, 20%, 18%)`,
gray_950: `hsl(${BLUE_HUE}, 20%, 10%)`,
gray_975: `hsl(${BLUE_HUE}, 20%, 7%)`,
gray_1000: `hsl(${BLUE_HUE}, 20%, 4%)`,
blue_0: `hsl(${BLUE_HUE}, 99%, 100%)`,
blue_100: `hsl(${BLUE_HUE}, 99%, 93%)`,
blue_200: `hsl(${BLUE_HUE}, 99%, 83%)`,
blue_300: `hsl(${BLUE_HUE}, 99%, 73%)`,
blue_400: `hsl(${BLUE_HUE}, 99%, 63%)`,
blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
blue_100: `hsl(${BLUE_HUE}, 99%, 90%)`,
blue_200: `hsl(${BLUE_HUE}, 99%, 80%)`,
blue_300: `hsl(${BLUE_HUE}, 99%, 70%)`,
blue_400: `hsl(${BLUE_HUE}, 99%, 60%)`,
blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
blue_600: `hsl(${BLUE_HUE}, 99%, 43%)`,
blue_700: `hsl(${BLUE_HUE}, 99%, 33%)`,
blue_800: `hsl(${BLUE_HUE}, 99%, 23%)`,
blue_900: `hsl(${BLUE_HUE}, 99%, 13%)`,
blue_1000: `hsl(${BLUE_HUE}, 99%, 8%)`,
blue_600: `hsl(${BLUE_HUE}, 99%, 42%)`,
blue_700: `hsl(${BLUE_HUE}, 99%, 34%)`,
blue_800: `hsl(${BLUE_HUE}, 99%, 26%)`,
blue_900: `hsl(${BLUE_HUE}, 99%, 18%)`,
blue_950: `hsl(${BLUE_HUE}, 99%, 10%)`,
blue_975: `hsl(${BLUE_HUE}, 99%, 7%)`,
green_0: `hsl(130, 60%, 100%)`,
green_100: `hsl(130, 60%, 95%)`,
green_200: `hsl(130, 60%, 85%)`,
green_300: `hsl(130, 60%, 75%)`,
green_400: `hsl(130, 60%, 65%)`,
green_500: `hsl(130, 60%, 55%)`,
green_600: `hsl(130, 60%, 45%)`,
green_700: `hsl(130, 60%, 35%)`,
green_800: `hsl(130, 60%, 25%)`,
green_900: `hsl(130, 60%, 15%)`,
green_1000: `hsl(130, 60%, 5%)`,
green_25: `hsl(${GREEN_HUE}, 82%, 97%)`,
green_50: `hsl(${GREEN_HUE}, 82%, 95%)`,
green_100: `hsl(${GREEN_HUE}, 82%, 90%)`,
green_200: `hsl(${GREEN_HUE}, 82%, 80%)`,
green_300: `hsl(${GREEN_HUE}, 82%, 70%)`,
green_400: `hsl(${GREEN_HUE}, 82%, 60%)`,
green_500: `hsl(${GREEN_HUE}, 82%, 50%)`,
green_600: `hsl(${GREEN_HUE}, 82%, 42%)`,
green_700: `hsl(${GREEN_HUE}, 82%, 34%)`,
green_800: `hsl(${GREEN_HUE}, 82%, 26%)`,
green_900: `hsl(${GREEN_HUE}, 82%, 18%)`,
green_950: `hsl(${GREEN_HUE}, 82%, 10%)`,
green_975: `hsl(${GREEN_HUE}, 82%, 7%)`,
red_0: `hsl(349, 96%, 100%)`,
red_100: `hsl(349, 96%, 95%)`,
red_200: `hsl(349, 96%, 85%)`,
red_300: `hsl(349, 96%, 75%)`,
red_400: `hsl(349, 96%, 65%)`,
red_500: `hsl(349, 96%, 55%)`,
red_600: `hsl(349, 96%, 45%)`,
red_700: `hsl(349, 96%, 35%)`,
red_800: `hsl(349, 96%, 25%)`,
red_900: `hsl(349, 96%, 15%)`,
red_1000: `hsl(349, 96%, 5%)`,
red_25: `hsl(${RED_HUE}, 91%, 97%)`,
red_50: `hsl(${RED_HUE}, 91%, 95%)`,
red_100: `hsl(${RED_HUE}, 91%, 90%)`,
red_200: `hsl(${RED_HUE}, 91%, 80%)`,
red_300: `hsl(${RED_HUE}, 91%, 70%)`,
red_400: `hsl(${RED_HUE}, 91%, 60%)`,
red_500: `hsl(${RED_HUE}, 91%, 50%)`,
red_600: `hsl(${RED_HUE}, 91%, 42%)`,
red_700: `hsl(${RED_HUE}, 91%, 34%)`,
red_800: `hsl(${RED_HUE}, 91%, 26%)`,
red_900: `hsl(${RED_HUE}, 91%, 18%)`,
red_950: `hsl(${RED_HUE}, 91%, 10%)`,
red_975: `hsl(${RED_HUE}, 91%, 7%)`,
} as const
export const space = {
xxs: 2,
_2xs: 2,
xs: 4,
sm: 8,
md: 12,
lg: 18,
xl: 24,
xxl: 32,
lg: 16,
xl: 20,
_2xl: 24,
_3xl: 28,
_4xl: 32,
_5xl: 40,
} as const
export const fontSize = {
xxs: 10,
_2xs: 10,
xs: 12,
sm: 14,
md: 16,
lg: 18,
xl: 22,
xxl: 26,
xl: 20,
_2xl: 22,
_3xl: 26,
_4xl: 32,
_5xl: 40,
} as const
// TODO test
export const lineHeight = {
none: 1,
normal: 1.5,
@@ -81,6 +97,8 @@ export const lineHeight = {
} as const
export const borderRadius = {
_2xs: 2,
xs: 4,
sm: 8,
md: 12,
full: 999,
@@ -92,6 +110,56 @@ export const fontWeight = {
bold: '900',
} as const
export const gradients = {
sky: {
values: [
[0, '#0A7AFF'],
[1, '#59B9FF'],
],
hover_value: '#0A7AFF',
},
midnight: {
values: [
[0, '#022C5E'],
[1, '#4079BC'],
],
hover_value: '#022C5E',
},
sunrise: {
values: [
[0, '#4E90AE'],
[0.4, '#AEA3AB'],
[0.8, '#E6A98F'],
[1, '#F3A84C'],
],
hover_value: '#AEA3AB',
},
sunset: {
values: [
[0, '#6772AF'],
[0.6, '#B88BB6'],
[1, '#FFA6AC'],
],
hover_value: '#B88BB6',
},
nordic: {
values: [
[0, '#083367'],
[1, '#9EE8C1'],
],
hover_value: '#3A7085',
},
bonfire: {
values: [
[0, '#203E4E'],
[0.4, '#755B62'],
[0.8, '#CD7765'],
[1, '#EF956E'],
],
hover_value: '#755B62',
},
} as const
export type Color = keyof typeof color
export type Space = keyof typeof space
export type FontSize = keyof typeof fontSize
+3
View File
@@ -0,0 +1,3 @@
import {StyleSheet} from 'react-native'
export const flatten = StyleSheet.flatten
+507
View File
@@ -0,0 +1,507 @@
import React from 'react'
import {
Pressable,
Text,
PressableProps,
TextProps,
ViewStyle,
AccessibilityProps,
View,
TextStyle,
StyleSheet,
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {useTheme, atoms as a, tokens, web, native} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common'
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
export type ButtonColor =
| 'primary'
| 'secondary'
| 'negative'
| 'gradient_sky'
| 'gradient_midnight'
| 'gradient_sunrise'
| 'gradient_sunset'
| 'gradient_nordic'
| 'gradient_bonfire'
export type ButtonSize = 'small' | 'large'
export type VariantProps = {
/**
* The style variation of the button
*/
variant?: ButtonVariant
/**
* The color of the button
*/
color?: ButtonColor
/**
* The size of the button
*/
size?: ButtonSize
}
export type ButtonProps = React.PropsWithChildren<
Pick<PressableProps, 'disabled' | 'onPress'> &
AccessibilityProps &
VariantProps & {
label: string
}
>
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
const Context = React.createContext<
VariantProps & {
hovered: boolean
focused: boolean
pressed: boolean
disabled: boolean
}
>({
hovered: false,
focused: false,
pressed: false,
disabled: false,
})
export function useButtonContext() {
return React.useContext(Context)
}
export function Button({
children,
variant,
color,
size,
label,
disabled = false,
...rest
}: ButtonProps) {
const t = useTheme()
const [state, setState] = React.useState({
pressed: false,
hovered: false,
focused: false,
})
const onPressIn = React.useCallback(() => {
setState(s => ({
...s,
pressed: true,
}))
}, [setState])
const onPressOut = React.useCallback(() => {
setState(s => ({
...s,
pressed: false,
}))
}, [setState])
const onHoverIn = React.useCallback(() => {
setState(s => ({
...s,
hovered: true,
}))
}, [setState])
const onHoverOut = React.useCallback(() => {
setState(s => ({
...s,
hovered: false,
}))
}, [setState])
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
focused: true,
}))
}, [setState])
const onBlur = React.useCallback(() => {
setState(s => ({
...s,
focused: false,
}))
}, [setState])
const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = []
const hoverStyles: ViewStyle[] = []
const light = t.name === 'light'
if (color === 'primary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.primary_500,
})
hoverStyles.push({
backgroundColor: t.palette.primary_600,
})
} else {
baseStyles.push({
backgroundColor: t.palette.primary_700,
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: tokens.color.blue_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
? t.palette.primary_50
: t.palette.primary_950,
})
} else {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? t.palette.primary_100
: t.palette.primary_900,
})
}
}
} else if (color === 'secondary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: light
? tokens.color.gray_100
: tokens.color.gray_900,
})
hoverStyles.push({
backgroundColor: light
? tokens.color.gray_200
: tokens.color.gray_950,
})
} else {
baseStyles.push({
backgroundColor: light
? tokens.color.gray_300
: tokens.color.gray_950,
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500,
})
hoverStyles.push(a.border, t.atoms.bg_contrast_50)
} else {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? tokens.color.gray_100
: tokens.color.gray_900,
})
}
}
} else if (color === 'negative') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.negative_400,
})
hoverStyles.push({
backgroundColor: t.palette.negative_500,
})
} else {
baseStyles.push({
backgroundColor: t.palette.negative_600,
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.negative_400,
})
hoverStyles.push(a.border, {
backgroundColor: light
? t.palette.negative_50
: t.palette.negative_975,
})
} else {
baseStyles.push(a.border, {
borderColor: light
? t.palette.negative_200
: t.palette.negative_900,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? t.palette.negative_100
: t.palette.negative_950,
})
}
}
}
if (size === 'large') {
baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_sm)
} else if (size === 'small') {
baseStyles.push({paddingVertical: 9}, a.px_md, a.rounded_sm, a.gap_sm)
}
return {
baseStyles,
hoverStyles,
focusStyles: [
...hoverStyles,
{
outline: 0,
} as ViewStyle,
],
}
}, [t, variant, color, size, disabled])
const {gradientColors, gradientHoverColors, gradientLocations} =
React.useMemo(() => {
const colors: string[] = []
const hoverColors: string[] = []
const locations: number[] = []
const gradient = {
primary: tokens.gradients.sky,
secondary: tokens.gradients.sky,
negative: tokens.gradients.sky,
gradient_sky: tokens.gradients.sky,
gradient_midnight: tokens.gradients.midnight,
gradient_sunrise: tokens.gradients.sunrise,
gradient_sunset: tokens.gradients.sunset,
gradient_nordic: tokens.gradients.nordic,
gradient_bonfire: tokens.gradients.bonfire,
}[color || 'primary']
if (variant === 'gradient') {
colors.push(...gradient.values.map(([_, color]) => color))
hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
locations.push(...gradient.values.map(([location, _]) => location))
}
return {
gradientColors: colors,
gradientHoverColors: hoverColors,
gradientLocations: locations,
}
}, [variant, color])
const context = React.useMemo(
() => ({
...state,
variant,
color,
size,
disabled: disabled || false,
}),
[state, variant, color, size, disabled],
)
return (
<Pressable
role="button"
accessibilityHint={undefined} // optional
{...rest}
aria-label={label}
aria-pressed={state.pressed}
accessibilityLabel={label}
disabled={disabled || false}
accessibilityState={{
disabled: disabled || false,
}}
style={[
a.flex_row,
a.align_center,
a.overflow_hidden,
...baseStyles,
...(state.hovered || state.pressed ? hoverStyles : []),
...(state.focused ? focusStyles : []),
]}
onPressIn={onPressIn}
onPressOut={onPressOut}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
onFocus={onFocus}
onBlur={onBlur}>
{variant === 'gradient' && (
<LinearGradient
colors={
state.hovered || state.pressed || state.focused
? gradientHoverColors
: gradientColors
}
locations={gradientLocations}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[a.absolute, a.inset_0]}
/>
)}
<Context.Provider value={context}>
{typeof children === 'string' ? (
<ButtonText>{children}</ButtonText>
) : (
children
)}
</Context.Provider>
</Pressable>
)
}
export function useSharedButtonTextStyles() {
const t = useTheme()
const {color, variant, disabled, size} = useButtonContext()
return React.useMemo(() => {
const baseStyles: TextStyle[] = []
const light = t.name === 'light'
if (color === 'primary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({color: t.palette.white})
} else {
baseStyles.push({color: t.palette.white, opacity: 0.5})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({
color: light ? t.palette.primary_600 : t.palette.primary_500,
})
} else {
baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({color: t.palette.primary_600})
} else {
baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
}
}
} else if (color === 'secondary') {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_700 : tokens.color.gray_100,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_700,
})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_600 : tokens.color.gray_300,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_700,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_600 : tokens.color.gray_300,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_600,
})
}
}
} else if (color === 'negative') {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
baseStyles.push({color: t.palette.white})
} else {
baseStyles.push({color: t.palette.white, opacity: 0.5})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({color: t.palette.negative_400})
} else {
baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({color: t.palette.negative_400})
} else {
baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
}
}
} else {
if (!disabled) {
baseStyles.push({color: t.palette.white})
} else {
baseStyles.push({color: t.palette.white, opacity: 0.5})
}
}
if (size === 'large') {
baseStyles.push(
a.text_md,
web({paddingBottom: 1}),
native({marginTop: 2}),
)
} else {
baseStyles.push(
a.text_md,
web({paddingBottom: 1}),
native({marginTop: 2}),
)
}
return StyleSheet.flatten(baseStyles)
}, [t, variant, color, size, disabled])
}
export function ButtonText({children, style, ...rest}: ButtonTextProps) {
const textStyles = useSharedButtonTextStyles()
return (
<Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
{children}
</Text>
)
}
export function ButtonIcon({
icon: Comp,
}: {
icon: React.ComponentType<SVGIconProps>
}) {
const {size} = useButtonContext()
const textStyles = useSharedButtonTextStyles()
return (
<View style={[a.z_20]}>
<Comp
size={size === 'large' ? 'md' : 'sm'}
style={[{color: textStyles.color, pointerEvents: 'none'}]}
/>
</View>
)
}
+35
View File
@@ -0,0 +1,35 @@
import React from 'react'
import {useDialogStateContext} from '#/state/dialogs'
import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types'
export const Context = React.createContext<DialogContextProps>({
close: () => {},
})
export function useDialogContext() {
return React.useContext(Context)
}
export function useDialogControl() {
const id = React.useId()
const control = React.useRef<DialogControlProps>({
open: () => {},
close: () => {},
})
const {activeDialogs} = useDialogStateContext()
React.useEffect(() => {
activeDialogs.current.set(id, control)
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
activeDialogs.current.delete(id)
}
}, [id, activeDialogs])
return {
ref: control,
open: () => control.current.open(),
close: () => control.current.close(),
}
}
+162
View File
@@ -0,0 +1,162 @@
import React, {useImperativeHandle} from 'react'
import {View, Dimensions} from 'react-native'
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetTextInput,
BottomSheetView,
} from '@gorhom/bottom-sheet'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useTheme, atoms as a} from '#/alf'
import {Portal} from '#/components/Portal'
import {createInput} from '#/components/forms/TextField'
import {
DialogOuterProps,
DialogControlProps,
DialogInnerProps,
} from '#/components/Dialog/types'
import {Context} from '#/components/Dialog/context'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
// @ts-ignore
export const Input = createInput(BottomSheetTextInput)
export function Outer({
children,
control,
onClose,
nativeOptions,
}: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme()
const sheet = React.useRef<BottomSheet>(null)
const sheetOptions = nativeOptions?.sheet || {}
const hasSnapPoints = !!sheetOptions.snapPoints
const open = React.useCallback<DialogControlProps['open']>((i = 0) => {
sheet.current?.snapToIndex(i)
}, [])
const close = React.useCallback(() => {
sheet.current?.close()
onClose?.()
}, [onClose])
useImperativeHandle(
control.ref,
() => ({
open,
close,
}),
[open, close],
)
const context = React.useMemo(() => ({close}), [close])
return (
<Portal>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
{...sheetOptions}
ref={sheet}
index={-1}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={props => (
<BottomSheetBackdrop
opacity={0.4}
appearsOnIndex={0}
disappearsOnIndex={-1}
{...props}
/>
)}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onClose={onClose}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
</Portal>
)
}
// TODO a11y props here, or is that handled by the sheet?
export function Inner(props: DialogInnerProps) {
const insets = useSafeAreaInsets()
return (
<BottomSheetView
style={[
a.p_lg,
a.pt_3xl,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
},
]}>
{props.children}
</BottomSheetView>
)
}
export function ScrollableInner(props: DialogInnerProps) {
const insets = useSafeAreaInsets()
return (
<BottomSheetScrollView
style={[
a.flex_1, // main diff is this
a.p_lg,
a.pt_3xl,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
]}>
{props.children}
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
</BottomSheetScrollView>
)
}
export function Handle() {
const t = useTheme()
return (
<View
style={[
a.absolute,
a.rounded_sm,
a.z_10,
{
top: a.pt_lg.paddingTop,
width: 35,
height: 4,
alignSelf: 'center',
backgroundColor: t.palette.contrast_900,
opacity: 0.5,
},
]}
/>
)
}
export function Close() {
return null
}
+194
View File
@@ -0,0 +1,194 @@
import React, {useImperativeHandle} from 'react'
import {View, TouchableWithoutFeedback} from 'react-native'
import {FocusScope} from '@tamagui/focus-scope'
import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useTheme, atoms as a, useBreakpoints, web} from '#/alf'
import {Portal} from '#/components/Portal'
import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
import {Context} from '#/components/Dialog/context'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
export {Input} from '#/components/forms/TextField'
const stopPropagation = (e: any) => e.stopPropagation()
export function Outer({
control,
onClose,
children,
}: React.PropsWithChildren<DialogOuterProps>) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const [isOpen, setIsOpen] = React.useState(false)
const [isVisible, setIsVisible] = React.useState(true)
const open = React.useCallback(() => {
setIsOpen(true)
}, [setIsOpen])
const close = React.useCallback(async () => {
setIsVisible(false)
await new Promise(resolve => setTimeout(resolve, 150))
setIsOpen(false)
setIsVisible(true)
onClose?.()
}, [onClose, setIsOpen])
useImperativeHandle(
control.ref,
() => ({
open,
close,
}),
[open, close],
)
React.useEffect(() => {
if (!isOpen) return
function handler(e: KeyboardEvent) {
if (e.key === 'Escape') close()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [isOpen, close])
const context = React.useMemo(
() => ({
close,
}),
[close],
)
return (
<>
{isOpen && (
<Portal>
<Context.Provider value={context}>
<TouchableWithoutFeedback
accessibilityHint={undefined}
accessibilityLabel={_(msg`Close active dialog`)}
onPress={close}>
<View
style={[
web(a.fixed),
a.inset_0,
a.z_10,
a.align_center,
gtMobile ? a.p_lg : a.p_md,
{overflowY: 'auto'},
]}>
{isVisible && (
<Animated.View
entering={FadeIn.duration(150)}
// exiting={FadeOut.duration(150)}
style={[
web(a.fixed),
a.inset_0,
{opacity: 0.5, backgroundColor: t.palette.black},
]}
/>
)}
<View
style={[
a.w_full,
a.z_20,
a.justify_center,
a.align_center,
{
minHeight: web('calc(90vh - 36px)') || undefined,
},
]}>
{isVisible ? children : null}
</View>
</View>
</TouchableWithoutFeedback>
</Context.Provider>
</Portal>
)}
</>
)
}
export function Inner({
children,
style,
label,
accessibilityLabelledBy,
accessibilityDescribedBy,
}: DialogInnerProps) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<FocusScope loop enabled trapped>
<Animated.View
role="dialog"
aria-role="dialog"
aria-label={label}
aria-labelledby={accessibilityLabelledBy}
aria-describedby={accessibilityDescribedBy}
// @ts-ignore web only -prf
onClick={stopPropagation}
onStartShouldSetResponder={_ => true}
onTouchEnd={stopPropagation}
entering={FadeInDown.duration(100)}
// exiting={FadeOut.duration(100)}
style={[
a.relative,
a.rounded_md,
a.w_full,
a.border,
gtMobile ? a.p_xl : a.p_lg,
t.atoms.bg,
{
maxWidth: 600,
borderColor: t.palette.contrast_200,
shadowColor: t.palette.black,
shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
shadowRadius: 30,
},
...(Array.isArray(style) ? style : [style || {}]),
]}>
{children}
</Animated.View>
</FocusScope>
)
}
export const ScrollableInner = Inner
export function Handle() {
return null
}
/**
* TODO(eric) unused rn
*/
// export function Close() {
// const {_} = useLingui()
// const t = useTheme()
// const {close} = useDialogContext()
// return (
// <View
// style={[
// a.absolute,
// a.z_10,
// {
// top: a.pt_lg.paddingTop,
// right: a.pr_lg.paddingRight,
// },
// ]}>
// <Button onPress={close} label={_(msg`Close active dialog`)}>
// </Button>
// </View>
// )
// }
+43
View File
@@ -0,0 +1,43 @@
import React from 'react'
import type {ViewStyle, AccessibilityProps} from 'react-native'
import {BottomSheetProps} from '@gorhom/bottom-sheet'
type A11yProps = Required<AccessibilityProps>
export type DialogContextProps = {
close: () => void
}
export type DialogControlProps = {
open: (index?: number) => void
close: () => void
}
export type DialogOuterProps = {
control: {
ref: React.RefObject<DialogControlProps>
open: (index?: number) => void
close: () => void
}
onClose?: () => void
nativeOptions?: {
sheet?: Omit<BottomSheetProps, 'children'>
}
webOptions?: {}
}
type DialogInnerPropsBase<T> = React.PropsWithChildren<{
style?: ViewStyle
}> &
T
export type DialogInnerProps =
| DialogInnerPropsBase<{
label?: undefined
accessibilityLabelledBy: A11yProps['aria-labelledby']
accessibilityDescribedBy: string
}>
| DialogInnerPropsBase<{
label: string
accessibilityLabelledBy?: undefined
accessibilityDescribedBy?: undefined
}>
+191
View File
@@ -0,0 +1,191 @@
import React from 'react'
import {
Text,
TextStyle,
StyleProp,
GestureResponderEvent,
Linking,
} from 'react-native'
import {
useLinkProps,
useNavigation,
StackActions,
} from '@react-navigation/native'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {isWeb} from '#/platform/detection'
import {useTheme, web, flatten} from '#/alf'
import {Button, ButtonProps, useButtonContext} from '#/components/Button'
import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {
convertBskyAppUrlIfNeeded,
isExternalUrl,
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
import {useModalControls} from '#/state/modals'
import {router} from '#/routes'
export type LinkProps = Omit<
ButtonProps,
'style' | 'onPress' | 'disabled' | 'label'
> & {
/**
* `TextStyle` to apply to the anchor element itself. Does not apply to any children.
*/
style?: StyleProp<TextStyle>
/**
* The React Navigation `StackAction` to perform when the link is pressed.
*/
action?: 'push' | 'replace' | 'navigate'
/**
* If true, will warn the user if the link text does not match the href. Only
* works for Links with children that are strings i.e. text links.
*/
warnOnMismatchingTextChild?: boolean
label?: ButtonProps['label']
} & Pick<Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to'>
/**
* A interactive element that renders as a `<a>` tag on the web. On mobile it
* will translate the `href` to navigator screens and params and dispatch a
* navigation action.
*
* Intended to behave as a web anchor tag. For more complex routing, use a
* `Button`.
*/
export function Link({
children,
to,
action = 'push',
warnOnMismatchingTextChild,
style,
...rest
}: LinkProps) {
const navigation = useNavigation<NavigationProp>()
const {href} = useLinkProps<AllNavigatorParams>({
to:
typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to,
})
const isExternal = isExternalUrl(href)
const {openModal, closeModal} = useModalControls()
const onPress = React.useCallback(
(e: GestureResponderEvent) => {
const stringChildren = typeof children === 'string' ? children : ''
const requiresWarning = Boolean(
warnOnMismatchingTextChild &&
stringChildren &&
isExternal &&
linkRequiresWarning(href, stringChildren),
)
if (requiresWarning) {
e.preventDefault()
openModal({
name: 'link-warning',
text: stringChildren,
href: href,
})
} else {
e.preventDefault()
if (isExternal) {
Linking.openURL(href)
} else {
/**
* A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
* of @ts-ignore below.
*/
const event = e as any
const isMiddleClick = isWeb && event.button === 1
const isMetaKey =
isWeb &&
(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
const shouldOpenInNewTab = isMetaKey || isMiddleClick
if (
shouldOpenInNewTab ||
href.startsWith('http') ||
href.startsWith('mailto')
) {
Linking.openURL(href)
} else {
closeModal() // close any active modals
if (action === 'push') {
navigation.dispatch(StackActions.push(...router.matchPath(href)))
} else if (action === 'replace') {
navigation.dispatch(
StackActions.replace(...router.matchPath(href)),
)
} else if (action === 'navigate') {
// @ts-ignore
navigation.navigate(...router.matchPath(href))
} else {
throw Error('Unsupported navigator action.')
}
}
}
}
},
[
href,
isExternal,
warnOnMismatchingTextChild,
navigation,
action,
children,
closeModal,
openModal,
],
)
return (
<Button
label={href}
{...rest}
role="link"
accessibilityRole="link"
href={href}
onPress={onPress}
{...web({
hrefAttrs: {
target: isExternal ? 'blank' : undefined,
rel: isExternal ? 'noopener noreferrer' : undefined,
},
dataSet: {
// default to no underline, apply this ourselves
noUnderline: '1',
},
})}>
{typeof children === 'string' ? (
<LinkText style={style}>{children}</LinkText>
) : (
children
)}
</Button>
)
}
function LinkText({
children,
style,
}: React.PropsWithChildren<{
style?: StyleProp<TextStyle>
}>) {
const t = useTheme()
const {hovered} = useButtonContext()
return (
<Text
style={[
{color: t.palette.primary_500},
hovered && {
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
},
flatten(style),
]}>
{children as string}
</Text>
)
}
+56
View File
@@ -0,0 +1,56 @@
import React from 'react'
type Component = React.ReactElement
type ContextType = {
outlet: Component | null
append(id: string, component: Component): void
remove(id: string): void
}
type ComponentMap = {
[id: string]: Component
}
export const Context = React.createContext<ContextType>({
outlet: null,
append: () => {},
remove: () => {},
})
export function Provider(props: React.PropsWithChildren<{}>) {
const map = React.useRef<ComponentMap>({})
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
const append = React.useCallback<ContextType['append']>((id, component) => {
if (map.current[id]) return
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const remove = React.useCallback<ContextType['remove']>(id => {
delete map.current[id]
setOutlet(<>{Object.values(map.current)}</>)
}, [])
return (
<Context.Provider value={{outlet, append, remove}}>
{props.children}
</Context.Provider>
)
}
export function Outlet() {
const ctx = React.useContext(Context)
return ctx.outlet
}
export function Portal({children}: React.PropsWithChildren<{}>) {
const {append, remove} = React.useContext(Context)
const id = React.useId()
React.useEffect(() => {
append(id, children as Component)
return () => remove(id)
}, [id, children, append, remove])
return null
}
+119
View File
@@ -0,0 +1,119 @@
import React from 'react'
import {View, PressableProps} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useTheme, atoms as a} from '#/alf'
import {H4, P} from '#/components/Typography'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
export {useDialogControl as usePromptControl} from '#/components/Dialog'
const Context = React.createContext<{
titleId: string
descriptionId: string
}>({
titleId: '',
descriptionId: '',
})
export function Outer({
children,
control,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
}>) {
const titleId = React.useId()
const descriptionId = React.useId()
const context = React.useMemo(
() => ({titleId, descriptionId}),
[titleId, descriptionId],
)
return (
<Dialog.Outer control={control}>
<Context.Provider value={context}>
<Dialog.Handle />
<Dialog.Inner
accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId}
style={{width: 'auto', maxWidth: 400}}>
{children}
</Dialog.Inner>
</Context.Provider>
</Dialog.Outer>
)
}
export function Title({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const {titleId} = React.useContext(Context)
return (
<H4
nativeID={titleId}
style={[a.font_bold, t.atoms.text_contrast_700, a.pb_sm]}>
{children}
</H4>
)
}
export function Description({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const {descriptionId} = React.useContext(Context)
return (
<P nativeID={descriptionId} style={[t.atoms.text, a.pb_lg]}>
{children}
</P>
)
}
export function Actions({children}: React.PropsWithChildren<{}>) {
return (
<View style={[a.w_full, a.flex_row, a.gap_sm, a.justify_end]}>
{children}
</View>
)
}
export function Cancel({
children,
}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) {
const {_} = useLingui()
const {close} = Dialog.useDialogContext()
return (
<Button
variant="solid"
color="secondary"
size="small"
label={_(msg`Cancel`)}
onPress={close}>
{children}
</Button>
)
}
export function Action({
children,
onPress,
}: React.PropsWithChildren<{onPress?: () => void}>) {
const {_} = useLingui()
const {close} = Dialog.useDialogContext()
const handleOnPress = React.useCallback(() => {
close()
onPress?.()
}, [close, onPress])
return (
<Button
variant="solid"
color="primary"
size="small"
label={_(msg`Confirm`)}
onPress={handleOnPress}>
{children}
</Button>
)
}
@@ -1,6 +1,7 @@
import React from 'react'
import {Text as RNText, TextProps} from 'react-native'
import {useTheme, atoms, web} from '#/alf'
import {useTheme, atoms, web, flatten} from '#/alf'
export function Text({style, ...rest}: TextProps) {
const t = useTheme()
@@ -18,7 +19,7 @@ export function H1({style, ...rest}: TextProps) {
<RNText
{...attr}
{...rest}
style={[atoms.text_xl, atoms.font_bold, t.atoms.text, style]}
style={[atoms.text_5xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
@@ -34,7 +35,7 @@ export function H2({style, ...rest}: TextProps) {
<RNText
{...attr}
{...rest}
style={[atoms.text_lg, atoms.font_bold, t.atoms.text, style]}
style={[atoms.text_4xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
@@ -50,7 +51,7 @@ export function H3({style, ...rest}: TextProps) {
<RNText
{...attr}
{...rest}
style={[atoms.text_md, atoms.font_bold, t.atoms.text, style]}
style={[atoms.text_3xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
@@ -66,7 +67,7 @@ export function H4({style, ...rest}: TextProps) {
<RNText
{...attr}
{...rest}
style={[atoms.text_sm, atoms.font_bold, t.atoms.text, style]}
style={[atoms.text_2xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
@@ -82,7 +83,7 @@ export function H5({style, ...rest}: TextProps) {
<RNText
{...attr}
{...rest}
style={[atoms.text_xs, atoms.font_bold, t.atoms.text, style]}
style={[atoms.text_xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
@@ -98,7 +99,26 @@ export function H6({style, ...rest}: TextProps) {
<RNText
{...attr}
{...rest}
style={[atoms.text_xxs, atoms.font_bold, t.atoms.text, style]}
style={[atoms.text_lg, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
export function P({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'paragraph',
}) || {}
const _style = flatten(style)
const lineHeight =
(_style?.lineHeight || atoms.text_md.lineHeight) *
atoms.leading_normal.lineHeight
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_md, t.atoms.text, _style, {lineHeight}]}
/>
)
}
@@ -0,0 +1,108 @@
import React from 'react'
import {View, Pressable} from 'react-native'
import DateTimePicker, {
BaseProps as DateTimePickerProps,
} from '@react-native-community/datetimepicker'
import {useTheme, atoms} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import * as TextField from '#/components/forms/TextField'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
import {DateFieldProps} from '#/components/forms/DateField/types'
import {
localizeDate,
toSimpleDateString,
} from '#/components/forms/DateField/utils'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
export function DateField({
value,
onChangeDate,
label,
isInvalid,
testID,
}: DateFieldProps) {
const t = useTheme()
const [open, setOpen] = React.useState(false)
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {chromeFocus, chromeError, chromeErrorHover} =
TextField.useSharedInputStyles()
const onChangeInternal = React.useCallback<
Required<DateTimePickerProps>['onChange']
>(
(_event, date) => {
setOpen(false)
if (date) {
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
}
},
[onChangeDate, setOpen],
)
return (
<View style={[atoms.relative, atoms.w_full]}>
<Pressable
aria-label={label}
accessibilityLabel={label}
accessibilityHint={undefined}
onPress={() => setOpen(true)}
onPressIn={onPressIn}
onPressOut={onPressOut}
onFocus={onFocus}
onBlur={onBlur}
style={[
{
paddingTop: 16,
paddingBottom: 16,
borderColor: 'transparent',
borderWidth: 2,
},
atoms.flex_row,
atoms.flex_1,
atoms.w_full,
atoms.px_lg,
atoms.rounded_sm,
t.atoms.bg_contrast_50,
focused || pressed ? chromeFocus : {},
isInvalid ? chromeError : {},
isInvalid && (focused || pressed) ? chromeErrorHover : {},
]}>
<TextField.Icon icon={CalendarDays} />
<Text
style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}>
{localizeDate(value)}
</Text>
</Pressable>
{open && (
<DateTimePicker
aria-label={label}
accessibilityLabel={label}
accessibilityHint={undefined}
testID={`${testID}-datepicker`}
mode="date"
timeZoneName={'Etc/UTC'}
display="spinner"
// @ts-ignore applies in iOS only -prf
themeVariant={t.name === 'dark' ? 'dark' : 'light'}
value={new Date(value)}
onChange={onChangeInternal}
/>
)}
</View>
)
}
+56
View File
@@ -0,0 +1,56 @@
import React from 'react'
import {View} from 'react-native'
import DateTimePicker, {
DateTimePickerEvent,
} from '@react-native-community/datetimepicker'
import {useTheme, atoms} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {toSimpleDateString} from '#/components/forms/DateField/utils'
import {DateFieldProps} from '#/components/forms/DateField/types'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
/**
* Date-only input. Accepts a date in the format YYYY-MM-DD, and reports date
* changes in the same format.
*
* For dates of unknown format, convert with the
* `utils.toSimpleDateString(Date)` export of this file.
*/
export function DateField({
value,
onChangeDate,
testID,
label,
}: DateFieldProps) {
const t = useTheme()
const onChangeInternal = React.useCallback(
(event: DateTimePickerEvent, date: Date | undefined) => {
if (date) {
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
}
},
[onChangeDate],
)
return (
<View style={[atoms.relative, atoms.w_full]}>
<DateTimePicker
aria-label={label}
accessibilityLabel={label}
accessibilityHint={undefined}
testID={`${testID}-datepicker`}
mode="date"
timeZoneName={'Etc/UTC'}
display="spinner"
themeVariant={t.name === 'dark' ? 'dark' : 'light'}
value={new Date(value)}
onChange={onChangeInternal}
/>
</View>
)
}
@@ -0,0 +1,64 @@
import React from 'react'
import {TextInput, TextInputProps, StyleSheet} from 'react-native'
// @ts-ignore
import {unstable_createElement} from 'react-native-web'
import * as TextField from '#/components/forms/TextField'
import {toSimpleDateString} from '#/components/forms/DateField/utils'
import {DateFieldProps} from '#/components/forms/DateField/types'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
const InputBase = React.forwardRef<HTMLInputElement, TextInputProps>(
({style, ...props}, ref) => {
return unstable_createElement('input', {
...props,
ref,
type: 'date',
style: [
StyleSheet.flatten(style),
{
background: 'transparent',
border: 0,
},
],
})
},
)
InputBase.displayName = 'InputBase'
const Input = TextField.createInput(InputBase as unknown as typeof TextInput)
export function DateField({
value,
onChangeDate,
label,
isInvalid,
testID,
}: DateFieldProps) {
const handleOnChange = React.useCallback(
(e: any) => {
const date = e.target.valueAsDate || e.target.value
if (date) {
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
}
},
[onChangeDate],
)
return (
<TextField.Root isInvalid={isInvalid}>
<Input
value={value}
label={label}
onChange={handleOnChange}
onChangeText={() => {}}
testID={testID}
/>
</TextField.Root>
)
}
+7
View File
@@ -0,0 +1,7 @@
export type DateFieldProps = {
value: string
onChangeDate: (date: string) => void
label: string
isInvalid?: boolean
testID?: string
}
+16
View File
@@ -0,0 +1,16 @@
import {getLocales} from 'expo-localization'
const LOCALE = getLocales()[0]
// we need the date in the form yyyy-MM-dd to pass to the input
export function toSimpleDateString(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date
return _date.toISOString().split('T')[0]
}
export function localizeDate(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date
return new Intl.DateTimeFormat(LOCALE.languageTag, {
timeZone: 'UTC',
}).format(_date)
}
+43
View File
@@ -0,0 +1,43 @@
import React from 'react'
import {View} from 'react-native'
import {atoms, useTheme} from '#/alf'
/**
* NOT FINISHED, just here as a reference
*/
export function InputGroup(props: React.PropsWithChildren<{}>) {
const t = useTheme()
const children = React.Children.toArray(props.children)
const total = children.length
return (
<View style={[atoms.w_full]}>
{children.map((child, i) => {
return React.isValidElement(child) ? (
<React.Fragment key={i}>
{i > 0 ? (
<View
style={[atoms.border_b, {borderColor: t.palette.contrast_500}]}
/>
) : null}
{React.cloneElement(child, {
// @ts-ignore
style: [
...(Array.isArray(child.props?.style)
? child.props.style
: [child.props.style || {}]),
{
borderTopLeftRadius: i > 0 ? 0 : undefined,
borderTopRightRadius: i > 0 ? 0 : undefined,
borderBottomLeftRadius: i < total - 1 ? 0 : undefined,
borderBottomRightRadius: i < total - 1 ? 0 : undefined,
borderBottomWidth: i < total - 1 ? 0 : undefined,
},
],
})}
</React.Fragment>
) : null
})}
</View>
)
}
+334
View File
@@ -0,0 +1,334 @@
import React from 'react'
import {
View,
TextInput,
TextInputProps,
TextStyle,
ViewStyle,
Pressable,
StyleSheet,
AccessibilityProps,
} from 'react-native'
import {HITSLOP_20} from 'lib/constants'
import {isWeb} from '#/platform/detection'
import {useTheme, atoms as a, web, tokens, android} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Props as SVGIconProps} from '#/components/icons/common'
const Context = React.createContext<{
inputRef: React.RefObject<TextInput> | null
isInvalid: boolean
hovered: boolean
onHoverIn: () => void
onHoverOut: () => void
focused: boolean
onFocus: () => void
onBlur: () => void
}>({
inputRef: null,
isInvalid: false,
hovered: false,
onHoverIn: () => {},
onHoverOut: () => {},
focused: false,
onFocus: () => {},
onBlur: () => {},
})
export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
export function Root({children, isInvalid = false}: RootProps) {
const inputRef = React.useRef<TextInput>(null)
const rootRef = React.useRef<View>(null)
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const context = React.useMemo(
() => ({
inputRef,
hovered,
onHoverIn,
onHoverOut,
focused,
onFocus,
onBlur,
isInvalid,
}),
[
inputRef,
hovered,
onHoverIn,
onHoverOut,
focused,
onFocus,
onBlur,
isInvalid,
],
)
React.useLayoutEffect(() => {
const root = rootRef.current
if (!root || !isWeb) return
// @ts-ignore web only
root.tabIndex = -1
}, [])
return (
<Context.Provider value={context}>
<Pressable
accessibilityRole="button"
ref={rootRef}
role="none"
style={[
a.flex_row,
a.align_center,
a.relative,
a.w_full,
a.px_md,
{
paddingVertical: 14,
},
]}
// onPressIn/out don't work on android web
onPress={() => inputRef.current?.focus()}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}>
{children}
</Pressable>
</Context.Provider>
)
}
export function useSharedInputStyles() {
const t = useTheme()
return React.useMemo(() => {
const hover: ViewStyle[] = [
{
borderColor: t.palette.contrast_100,
},
]
const focus: ViewStyle[] = [
{
backgroundColor: t.palette.contrast_50,
borderColor: t.palette.primary_500,
},
]
const error: ViewStyle[] = [
{
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
},
]
const errorHover: ViewStyle[] = [
{
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: tokens.color.red_500,
},
]
return {
chromeHover: StyleSheet.flatten(hover),
chromeFocus: StyleSheet.flatten(focus),
chromeError: StyleSheet.flatten(error),
chromeErrorHover: StyleSheet.flatten(errorHover),
}
}, [t])
}
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
label: string
value: string
onChangeText: (value: string) => void
isInvalid?: boolean
}
export function createInput(Component: typeof TextInput) {
return function Input({
label,
placeholder,
value,
onChangeText,
isInvalid,
...rest
}: InputProps) {
const t = useTheme()
const ctx = React.useContext(Context)
const withinRoot = Boolean(ctx.inputRef)
const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
useSharedInputStyles()
if (!withinRoot) {
return (
<Root isInvalid={isInvalid}>
<Input
label={label}
placeholder={placeholder}
value={value}
onChangeText={onChangeText}
isInvalid={isInvalid}
{...rest}
/>
</Root>
)
}
return (
<>
<Component
accessibilityHint={undefined}
{...rest}
aria-label={label}
accessibilityLabel={label}
ref={ctx.inputRef}
value={value}
onChangeText={onChangeText}
onFocus={ctx.onFocus}
onBlur={ctx.onBlur}
placeholder={placeholder || label}
placeholderTextColor={t.palette.contrast_500}
hitSlop={HITSLOP_20}
style={[
a.relative,
a.z_20,
a.flex_1,
a.text_md,
t.atoms.text,
a.px_xs,
android({
paddingBottom: 2,
}),
{
lineHeight: a.text_md.lineHeight * 1.1875,
textAlignVertical: rest.multiline ? 'top' : undefined,
minHeight: rest.multiline ? 60 : undefined,
},
]}
/>
<View
style={[
a.z_10,
a.absolute,
a.inset_0,
a.rounded_sm,
t.atoms.bg_contrast_25,
{borderColor: 'transparent', borderWidth: 2},
ctx.hovered ? chromeHover : {},
ctx.focused ? chromeFocus : {},
ctx.isInvalid || isInvalid ? chromeError : {},
(ctx.isInvalid || isInvalid) && (ctx.hovered || ctx.focused)
? chromeErrorHover
: {},
]}
/>
</>
)
}
}
export const Input = createInput(TextInput)
export function Label({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
return (
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_600, a.mb_sm]}>
{children}
</Text>
)
}
export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
const t = useTheme()
const ctx = React.useContext(Context)
const {hover, focus, errorHover, errorFocus} = React.useMemo(() => {
const hover: TextStyle[] = [
{
color: t.palette.contrast_800,
},
]
const focus: TextStyle[] = [
{
color: t.palette.primary_500,
},
]
const errorHover: TextStyle[] = [
{
color: t.palette.negative_500,
},
]
const errorFocus: TextStyle[] = [
{
color: t.palette.negative_500,
},
]
return {
hover,
focus,
errorHover,
errorFocus,
}
}, [t])
return (
<View style={[a.z_20, a.pr_xs]}>
<Comp
size="md"
style={[
{color: t.palette.contrast_500, pointerEvents: 'none'},
ctx.hovered ? hover : {},
ctx.focused ? focus : {},
ctx.isInvalid && ctx.hovered ? errorHover : {},
ctx.isInvalid && ctx.focused ? errorFocus : {},
]}
/>
</View>
)
}
export function Suffix({
children,
label,
accessibilityHint,
}: React.PropsWithChildren<{
label: string
accessibilityHint?: AccessibilityProps['accessibilityHint']
}>) {
const t = useTheme()
const ctx = React.useContext(Context)
return (
<Text
aria-label={label}
accessibilityLabel={label}
accessibilityHint={accessibilityHint}
style={[
a.z_20,
a.pr_sm,
a.text_md,
t.atoms.text_contrast_400,
{
pointerEvents: 'none',
},
web({
marginTop: -2,
}),
ctx.hovered || ctx.focused
? {
color: t.palette.contrast_800,
}
: {},
]}>
{children}
</Text>
)
}
+473
View File
@@ -0,0 +1,473 @@
import React from 'react'
import {Pressable, View, ViewStyle} from 'react-native'
import {HITSLOP_10} from 'lib/constants'
import {useTheme, atoms as a, web, native} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
export type ItemState = {
name: string
selected: boolean
disabled: boolean
isInvalid: boolean
hovered: boolean
pressed: boolean
focused: boolean
}
const ItemContext = React.createContext<ItemState>({
name: '',
selected: false,
disabled: false,
isInvalid: false,
hovered: false,
pressed: false,
focused: false,
})
const GroupContext = React.createContext<{
values: string[]
disabled: boolean
type: 'radio' | 'checkbox'
maxSelectionsReached: boolean
setFieldValue: (props: {name: string; value: boolean}) => void
}>({
type: 'checkbox',
values: [],
disabled: false,
maxSelectionsReached: false,
setFieldValue: () => {},
})
export type GroupProps = React.PropsWithChildren<{
type?: 'radio' | 'checkbox'
values: string[]
maxSelections?: number
disabled?: boolean
onChange: (value: string[]) => void
label: string
}>
export type ItemProps = {
type?: 'radio' | 'checkbox'
name: string
label: string
value?: boolean
disabled?: boolean
onChange?: (selected: boolean) => void
isInvalid?: boolean
style?: (state: ItemState) => ViewStyle
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
}
export function useItemContext() {
return React.useContext(ItemContext)
}
export function Group({
children,
values: providedValues,
onChange,
disabled = false,
type = 'checkbox',
maxSelections,
label,
}: GroupProps) {
const groupRole = type === 'radio' ? 'radiogroup' : undefined
const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
const [maxReached, setMaxReached] = React.useState(false)
const setFieldValue = React.useCallback<
(props: {name: string; value: boolean}) => void
>(
({name, value}) => {
if (type === 'checkbox') {
const pruned = values.filter(v => v !== name)
const next = value ? pruned.concat(name) : pruned
onChange(next)
} else {
onChange([name])
}
},
[type, onChange, values],
)
React.useEffect(() => {
if (type === 'checkbox') {
if (
maxSelections &&
values.length >= maxSelections &&
maxReached === false
) {
setMaxReached(true)
} else if (
maxSelections &&
values.length < maxSelections &&
maxReached === true
) {
setMaxReached(false)
}
}
}, [type, values.length, maxSelections, maxReached, setMaxReached])
const context = React.useMemo(
() => ({
values,
type,
disabled,
maxSelectionsReached: maxReached,
setFieldValue,
}),
[values, disabled, type, maxReached, setFieldValue],
)
return (
<GroupContext.Provider value={context}>
<View
role={groupRole}
{...(groupRole === 'radiogroup'
? {
'aria-label': label,
accessibilityLabel: label,
accessibilityRole: groupRole,
}
: {})}>
{children}
</View>
</GroupContext.Provider>
)
}
export function Item({
children,
name,
value = false,
disabled: itemDisabled = false,
onChange,
isInvalid,
style,
type = 'checkbox',
label,
...rest
}: ItemProps) {
const {
values: selectedValues,
type: groupType,
disabled: groupDisabled,
setFieldValue,
maxSelectionsReached,
} = React.useContext(GroupContext)
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const role = groupType === 'radio' ? 'radio' : type
const selected = selectedValues.includes(name) || !!value
const disabled =
groupDisabled || itemDisabled || (!selected && maxSelectionsReached)
const onPress = React.useCallback(() => {
const next = !selected
setFieldValue({name, value: next})
onChange?.(next)
}, [name, selected, onChange, setFieldValue])
const state = React.useMemo(
() => ({
name,
selected,
disabled: disabled ?? false,
isInvalid: isInvalid ?? false,
hovered,
pressed,
focused,
}),
[name, selected, disabled, hovered, pressed, focused, isInvalid],
)
return (
<ItemContext.Provider value={state}>
<Pressable
accessibilityHint={undefined} // optional
hitSlop={HITSLOP_10}
{...rest}
disabled={disabled}
aria-disabled={disabled ?? false}
aria-checked={selected}
aria-invalid={isInvalid}
aria-label={label}
role={role}
accessibilityRole={role}
accessibilityState={{
disabled: disabled ?? false,
selected: selected,
}}
accessibilityLabel={label}
onPress={onPress}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
onPressIn={onPressIn}
onPressOut={onPressOut}
onFocus={onFocus}
onBlur={onBlur}
style={[
a.flex_row,
a.align_center,
a.gap_sm,
focused ? web({outline: 'none'}) : {},
style?.(state),
]}>
{typeof children === 'function' ? children(state) : children}
</Pressable>
</ItemContext.Provider>
)
}
export function Label({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const {disabled} = useItemContext()
return (
<Text
style={[
a.font_bold,
{
userSelect: 'none',
color: disabled ? t.palette.contrast_400 : t.palette.contrast_600,
},
native({
paddingTop: 3,
}),
]}>
{children}
</Text>
)
}
// TODO(eric) refactor to memoize styles without knowledge of state
export function createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
}: {
theme: ReturnType<typeof useTheme>
selected: boolean
hovered: boolean
focused: boolean
disabled: boolean
isInvalid: boolean
}) {
const base: ViewStyle[] = []
const baseHover: ViewStyle[] = []
const indicator: ViewStyle[] = []
if (selected) {
base.push({
backgroundColor:
t.name === 'light' ? t.palette.primary_25 : t.palette.primary_900,
borderColor: t.palette.primary_500,
})
if (hovered || focused) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
borderColor:
t.name === 'light' ? t.palette.primary_600 : t.palette.primary_400,
})
}
} else {
if (hovered || focused) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
borderColor: t.palette.contrast_500,
})
}
}
if (isInvalid) {
base.push({
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
})
if (hovered || focused) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: t.palette.negative_500,
})
}
}
if (disabled) {
base.push({
backgroundColor: t.palette.contrast_100,
borderColor: t.palette.contrast_400,
})
}
return {
baseStyles: base,
baseHoverStyles: disabled ? [] : baseHover,
indicatorStyles: indicator,
}
}
export function Checkbox() {
const t = useTheme()
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
const {baseStyles, baseHoverStyles, indicatorStyles} =
createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
})
return (
<View
style={[
a.justify_center,
a.align_center,
a.border,
a.rounded_xs,
t.atoms.border_contrast,
{
height: 20,
width: 20,
},
baseStyles,
hovered || focused ? baseHoverStyles : {},
]}>
{selected ? (
<View
style={[
a.absolute,
a.rounded_2xs,
{height: 12, width: 12},
selected
? {
backgroundColor: t.palette.primary_500,
}
: {},
indicatorStyles,
]}
/>
) : null}
</View>
)
}
export function Switch() {
const t = useTheme()
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
const {baseStyles, baseHoverStyles, indicatorStyles} =
createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
})
return (
<View
style={[
a.relative,
a.border,
a.rounded_full,
t.atoms.bg,
t.atoms.border_contrast,
{
height: 20,
width: 30,
},
baseStyles,
hovered || focused ? baseHoverStyles : {},
]}>
<View
style={[
a.absolute,
a.rounded_full,
{
height: 12,
width: 12,
top: 3,
left: 3,
backgroundColor: t.palette.contrast_400,
},
selected
? {
backgroundColor: t.palette.primary_500,
left: 13,
}
: {},
indicatorStyles,
]}
/>
</View>
)
}
export function Radio() {
const t = useTheme()
const {selected, hovered, focused, disabled, isInvalid} =
React.useContext(ItemContext)
const {baseStyles, baseHoverStyles, indicatorStyles} =
createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
})
return (
<View
style={[
a.justify_center,
a.align_center,
a.border,
a.rounded_full,
t.atoms.border_contrast,
{
height: 20,
width: 20,
},
baseStyles,
hovered || focused ? baseHoverStyles : {},
]}>
{selected ? (
<View
style={[
a.absolute,
a.rounded_full,
{height: 12, width: 12},
selected
? {
backgroundColor: t.palette.primary_500,
}
: {},
indicatorStyles,
]}
/>
) : null}
</View>
)
}
+124
View File
@@ -0,0 +1,124 @@
import React from 'react'
import {View, AccessibilityProps, TextStyle, ViewStyle} from 'react-native'
import {atoms as a, useTheme, native} from '#/alf'
import {Text} from '#/components/Typography'
import * as Toggle from '#/components/forms/Toggle'
export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
AccessibilityProps &
React.PropsWithChildren<{}>
export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
multiple?: boolean
}
export function Group({children, multiple, ...props}: GroupProps) {
const t = useTheme()
return (
<Toggle.Group type={multiple ? 'checkbox' : 'radio'} {...props}>
<View
style={[
a.flex_row,
a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border,
]}>
{children}
</View>
</Toggle.Group>
)
}
export function Button({children, ...props}: ItemProps) {
return (
<Toggle.Item {...props}>
<ButtonInner>{children}</ButtonInner>
</Toggle.Item>
)
}
function ButtonInner({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const state = Toggle.useItemContext()
const {baseStyles, hoverStyles, activeStyles, textStyles} =
React.useMemo(() => {
const base: ViewStyle[] = []
const hover: ViewStyle[] = []
const active: ViewStyle[] = []
const text: TextStyle[] = []
hover.push(
t.name === 'light' ? t.atoms.bg_contrast_100 : t.atoms.bg_contrast_25,
)
if (state.selected) {
active.push({
backgroundColor: t.palette.contrast_800,
})
text.push(t.atoms.text_inverted)
hover.push({
backgroundColor: t.palette.contrast_800,
})
if (state.disabled) {
active.push({
backgroundColor: t.palette.contrast_500,
})
}
}
if (state.disabled) {
base.push({
backgroundColor: t.palette.contrast_100,
})
text.push({
opacity: 0.5,
})
}
return {
baseStyles: base,
hoverStyles: hover,
activeStyles: active,
textStyles: text,
}
}, [t, state])
return (
<View
style={[
{
borderLeftWidth: 1,
marginLeft: -1,
},
a.px_lg,
a.py_md,
native({
paddingTop: 14,
}),
t.atoms.bg,
t.atoms.border,
baseStyles,
activeStyles,
(state.hovered || state.focused || state.pressed) && hoverStyles,
]}>
{typeof children === 'string' ? (
<Text
style={[
a.text_center,
a.font_bold,
t.atoms.text_contrast_500,
textStyles,
]}>
{children}
</Text>
) : (
children
)}
</View>
)
}
@@ -0,0 +1,21 @@
import React from 'react'
export function useInteractionState() {
const [state, setState] = React.useState(false)
const onIn = React.useCallback(() => {
setState(true)
}, [setState])
const onOut = React.useCallback(() => {
setState(false)
}, [setState])
return React.useMemo(
() => ({
state,
onIn,
onOut,
}),
[state, onIn, onOut],
)
}
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const CalendarDays_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V9h14v10H5ZM5 7h14V5H5v2Zm3 10.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM17.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 13.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 17.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ColorPalette_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 12c0-4.09 3.527-7.5 8-7.5s8 3.41 8 7.5c0 1.579-.419 2.056-.708 2.236-.388.241-1.031.286-2.058.153-.33-.043-.652-.096-.991-.152a65.905 65.905 0 0 0-.531-.087c-.52-.081-1.077-.156-1.61-.164-1.065-.016-2.336.245-2.996 1.567-.418.834-.295 1.67-.078 2.314.18.534.47 1.055.683 1.437v.001l.097.175.01.018C7.432 19.407 4 16.033 4 12Zm8-9.5C6.532 2.5 2 6.7 2 12s4.532 9.5 10 9.5c.401 0 .812-.04 1.166-.193.41-.176.761-.517.866-1.028.085-.416-.03-.796-.118-1.029a5.981 5.981 0 0 0-.351-.73l-.12-.215c-.215-.392-.403-.73-.52-1.078-.13-.387-.111-.614-.029-.78.146-.291.404-.473 1.178-.461.385.005.825.06 1.329.14.15.023.308.05.47.077.36.059.742.122 1.105.17 1.021.132 2.325.213 3.373-.439C21.496 15.22 22 13.874 22 12c0-5.3-4.532-9.5-10-9.5Zm3.5 8.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM9 12.25a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm1.5-2.75a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Globe_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z',
})
+48
View File
@@ -0,0 +1,48 @@
import React from 'react'
import Svg, {Path} from 'react-native-svg'
import {useCommonSVGProps, Props} from '#/components/icons/common'
export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef(
function LogoImpl(props: Props, ref) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
return (
<Svg
fill="none"
{...rest}
// @ts-ignore it's fiiiiine
ref={ref}
viewBox="0 0 24 24"
width={size}
height={size}
style={[style]}>
<Path
fill={fill}
fillRule="evenodd"
clipRule="evenodd"
d="M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z"
/>
</Svg>
)
},
)
export function createSinglePathSVG({path}: {path: string}) {
return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
return (
<Svg
fill="none"
{...rest}
ref={ref}
viewBox="0 0 24 24"
width={size}
height={size}
style={[style]}>
<Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} />
</Svg>
)
})
}
+32
View File
@@ -0,0 +1,32 @@
import {StyleSheet, TextProps} from 'react-native'
import type {SvgProps, PathProps} from 'react-native-svg'
import {tokens} from '#/alf'
export type Props = {
fill?: PathProps['fill']
style?: TextProps['style']
size?: keyof typeof sizes
} & Omit<SvgProps, 'style' | 'size'>
export const sizes = {
xs: 12,
sm: 16,
md: 20,
lg: 24,
xl: 28,
}
export function useCommonSVGProps(props: Props) {
const {fill, size, ...rest} = props
const style = StyleSheet.flatten(rest.style)
const _fill = fill || style?.color || tokens.color.blue_500
const _size = Number(size ? sizes[size] : rest.width || sizes.md)
return {
fill: _fill,
size: _size,
style,
...rest,
}
}
+18 -9
View File
@@ -4,15 +4,20 @@ import {
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
import {getContentLanguages} from '#/state/preferences/languages'
export class CustomFeedAPI implements FeedAPI {
constructor(public params: GetCustomFeed.QueryParams) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().app.bsky.feed.getFeed({
...this.params,
limit: 1,
})
const contentLangs = getContentLanguages().join(',')
const res = await getAgent().app.bsky.feed.getFeed(
{
...this.params,
limit: 1,
},
{headers: {'Accept-Language': contentLangs}},
)
return res.data.feed[0]
}
@@ -23,11 +28,15 @@ export class CustomFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().app.bsky.feed.getFeed({
...this.params,
cursor,
limit,
})
const contentLangs = getContentLanguages().join(',')
const res = await getAgent().app.bsky.feed.getFeed(
{
...this.params,
cursor,
limit,
},
{headers: {'Accept-Language': contentLangs}},
)
if (res.success) {
// NOTE
// some custom feeds fail to enforce the pagination limit
+89
View File
@@ -0,0 +1,89 @@
import {AppBskyFeedDefs} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {FollowingFeedAPI} from './following'
import {CustomFeedAPI} from './custom'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
// HACK
// the feed API does not include any facilities for passing down
// non-post elements. adding that is a bit of a heavy lift, and we
// have just one temporary usecase for it: flagging when the home feed
// falls back to discover.
// we use this fallback marker post to drive this instead. see Feed.tsx
// for the usage.
// -prf
export const FALLBACK_MARKER_POST: AppBskyFeedDefs.FeedViewPost = {
post: {
uri: 'fallback-marker-post',
cid: 'fake',
record: {},
author: {
did: 'did:fake',
handle: 'fake.com',
},
indexedAt: new Date().toISOString(),
},
}
export class HomeFeedAPI implements FeedAPI {
following: FollowingFeedAPI
discover: CustomFeedAPI
usingDiscover = false
itemCursor = 0
constructor() {
this.following = new FollowingFeedAPI()
this.discover = new CustomFeedAPI({feed: PROD_DEFAULT_FEED('whats-hot')})
}
reset() {
this.following = new FollowingFeedAPI()
this.discover = new CustomFeedAPI({feed: PROD_DEFAULT_FEED('whats-hot')})
this.usingDiscover = false
this.itemCursor = 0
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
if (this.usingDiscover) {
return this.discover.peekLatest()
}
return this.following.peekLatest()
}
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
if (!cursor) {
this.reset()
}
let returnCursor
let posts: AppBskyFeedDefs.FeedViewPost[] = []
if (!this.usingDiscover) {
const res = await this.following.fetch({cursor, limit})
returnCursor = res.cursor
posts = posts.concat(res.feed)
if (!returnCursor) {
cursor = ''
posts.push(FALLBACK_MARKER_POST)
this.usingDiscover = true
}
}
if (this.usingDiscover) {
const res = await this.discover.fetch({cursor, limit})
returnCursor = res.cursor
posts = posts.concat(res.feed)
}
return {
cursor: returnCursor,
feed: posts,
}
}
}
+11 -6
View File
@@ -8,6 +8,7 @@ import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
import {FeedParams} from '#/state/queries/post-feed'
import {FeedTunerFn} from '../feed-manip'
import {getAgent} from '#/state/session'
import {getContentLanguages} from '#/state/preferences/languages'
const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@@ -25,7 +26,7 @@ export class MergeFeedAPI implements FeedAPI {
reset() {
this.following = new MergeFeedSource_Following(this.feedTuners)
this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
this.customFeeds = []
this.feedCursor = 0
this.itemCursor = 0
this.sampleCursor = 0
@@ -231,11 +232,15 @@ class MergeFeedSource_Custom extends MergeFeedSource {
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
try {
const res = await getAgent().app.bsky.feed.getFeed({
cursor,
limit,
feed: this.feedUri,
})
const contentLangs = getContentLanguages().join(',')
const res = await getAgent().app.bsky.feed.getFeed(
{
cursor,
limit,
feed: this.feedUri,
},
{headers: {'Accept-Language': contentLangs}},
)
// NOTE
// some custom feeds fail to enforce the pagination limit
// so we manually truncate here
+253
View File
@@ -0,0 +1,253 @@
import {CountryCode} from 'libphonenumber-js'
// ISO 3166-1 alpha-2 codes
export interface CountryCodeMap {
code2: CountryCode
name: string
}
export const COUNTRY_CODES: CountryCodeMap[] = [
{code2: 'AF', name: 'Afghanistan (+93)'},
{code2: 'AX', name: 'Åland Islands (+358)'},
{code2: 'AL', name: 'Albania (+355)'},
{code2: 'DZ', name: 'Algeria (+213)'},
{code2: 'AS', name: 'American Samoa (+1)'},
{code2: 'AD', name: 'Andorra (+376)'},
{code2: 'AO', name: 'Angola (+244)'},
{code2: 'AI', name: 'Anguilla (+1)'},
{code2: 'AG', name: 'Antigua and Barbuda (+1)'},
{code2: 'AR', name: 'Argentina (+54)'},
{code2: 'AM', name: 'Armenia (+374)'},
{code2: 'AW', name: 'Aruba (+297)'},
{code2: 'AU', name: 'Australia (+61)'},
{code2: 'AT', name: 'Austria (+43)'},
{code2: 'AZ', name: 'Azerbaijan (+994)'},
{code2: 'BS', name: 'Bahamas (+1)'},
{code2: 'BH', name: 'Bahrain (+973)'},
{code2: 'BD', name: 'Bangladesh (+880)'},
{code2: 'BB', name: 'Barbados (+1)'},
{code2: 'BY', name: 'Belarus (+375)'},
{code2: 'BE', name: 'Belgium (+32)'},
{code2: 'BZ', name: 'Belize (+501)'},
{code2: 'BJ', name: 'Benin (+229)'},
{code2: 'BM', name: 'Bermuda (+1)'},
{code2: 'BT', name: 'Bhutan (+975)'},
{code2: 'BO', name: 'Bolivia (Plurinational State of) (+591)'},
{code2: 'BQ', name: 'Bonaire, Sint Eustatius and Saba (+599)'},
{code2: 'BA', name: 'Bosnia and Herzegovina (+387)'},
{code2: 'BW', name: 'Botswana (+267)'},
{code2: 'BR', name: 'Brazil (+55)'},
{code2: 'IO', name: 'British Indian Ocean Territory (+246)'},
{code2: 'BN', name: 'Brunei Darussalam (+673)'},
{code2: 'BG', name: 'Bulgaria (+359)'},
{code2: 'BF', name: 'Burkina Faso (+226)'},
{code2: 'BI', name: 'Burundi (+257)'},
{code2: 'CV', name: 'Cabo Verde (+238)'},
{code2: 'KH', name: 'Cambodia (+855)'},
{code2: 'CM', name: 'Cameroon (+237)'},
{code2: 'CA', name: 'Canada (+1)'},
{code2: 'KY', name: 'Cayman Islands (+1)'},
{code2: 'CF', name: 'Central African Republic (+236)'},
{code2: 'TD', name: 'Chad (+235)'},
{code2: 'CL', name: 'Chile (+56)'},
{code2: 'CN', name: 'China (+86)'},
{code2: 'CX', name: 'Christmas Island (+61)'},
{code2: 'CC', name: 'Cocos (Keeling) Islands (+61)'},
{code2: 'CO', name: 'Colombia (+57)'},
{code2: 'KM', name: 'Comoros (+269)'},
{code2: 'CG', name: 'Congo (+242)'},
{code2: 'CD', name: 'Congo, Democratic Republic of the (+243)'},
{code2: 'CK', name: 'Cook Islands (+682)'},
{code2: 'CR', name: 'Costa Rica (+506)'},
{code2: 'CI', name: "Côte d'Ivoire (+225)"},
{code2: 'HR', name: 'Croatia (+385)'},
{code2: 'CU', name: 'Cuba (+53)'},
{code2: 'CW', name: 'Curaçao (+599)'},
{code2: 'CY', name: 'Cyprus (+357)'},
{code2: 'CZ', name: 'Czechia (+420)'},
{code2: 'DK', name: 'Denmark (+45)'},
{code2: 'DJ', name: 'Djibouti (+253)'},
{code2: 'DM', name: 'Dominica (+1)'},
{code2: 'DO', name: 'Dominican Republic (+1)'},
{code2: 'EC', name: 'Ecuador (+593)'},
{code2: 'EG', name: 'Egypt (+20)'},
{code2: 'SV', name: 'El Salvador (+503)'},
{code2: 'GQ', name: 'Equatorial Guinea (+240)'},
{code2: 'ER', name: 'Eritrea (+291)'},
{code2: 'EE', name: 'Estonia (+372)'},
{code2: 'SZ', name: 'Eswatini (+268)'},
{code2: 'ET', name: 'Ethiopia (+251)'},
{code2: 'FK', name: 'Falkland Islands (Malvinas) (+500)'},
{code2: 'FO', name: 'Faroe Islands (+298)'},
{code2: 'FJ', name: 'Fiji (+679)'},
{code2: 'FI', name: 'Finland (+358)'},
{code2: 'FR', name: 'France (+33)'},
{code2: 'GF', name: 'French Guiana (+594)'},
{code2: 'PF', name: 'French Polynesia (+689)'},
{code2: 'GA', name: 'Gabon (+241)'},
{code2: 'GM', name: 'Gambia (+220)'},
{code2: 'GE', name: 'Georgia (+995)'},
{code2: 'DE', name: 'Germany (+49)'},
{code2: 'GH', name: 'Ghana (+233)'},
{code2: 'GI', name: 'Gibraltar (+350)'},
{code2: 'GR', name: 'Greece (+30)'},
{code2: 'GL', name: 'Greenland (+299)'},
{code2: 'GD', name: 'Grenada (+1)'},
{code2: 'GP', name: 'Guadeloupe (+590)'},
{code2: 'GU', name: 'Guam (+1)'},
{code2: 'GT', name: 'Guatemala (+502)'},
{code2: 'GG', name: 'Guernsey (+44)'},
{code2: 'GN', name: 'Guinea (+224)'},
{code2: 'GW', name: 'Guinea-Bissau (+245)'},
{code2: 'GY', name: 'Guyana (+592)'},
{code2: 'HT', name: 'Haiti (+509)'},
{code2: 'VA', name: 'Holy See (+39)'},
{code2: 'HN', name: 'Honduras (+504)'},
{code2: 'HK', name: 'Hong Kong (+852)'},
{code2: 'HU', name: 'Hungary (+36)'},
{code2: 'IS', name: 'Iceland (+354)'},
{code2: 'IN', name: 'India (+91)'},
{code2: 'ID', name: 'Indonesia (+62)'},
{code2: 'IR', name: 'Iran (Islamic Republic of) (+98)'},
{code2: 'IQ', name: 'Iraq (+964)'},
{code2: 'IE', name: 'Ireland (+353)'},
{code2: 'IM', name: 'Isle of Man (+44)'},
{code2: 'IL', name: 'Israel (+972)'},
{code2: 'IT', name: 'Italy (+39)'},
{code2: 'JM', name: 'Jamaica (+1)'},
{code2: 'JP', name: 'Japan (+81)'},
{code2: 'JE', name: 'Jersey (+44)'},
{code2: 'JO', name: 'Jordan (+962)'},
{code2: 'KZ', name: 'Kazakhstan (+7)'},
{code2: 'KE', name: 'Kenya (+254)'},
{code2: 'KI', name: 'Kiribati (+686)'},
{code2: 'KP', name: "Korea (Democratic People's Republic of) (+850)"},
{code2: 'KR', name: 'Korea, Republic of (+82)'},
{code2: 'KW', name: 'Kuwait (+965)'},
{code2: 'KG', name: 'Kyrgyzstan (+996)'},
{code2: 'LA', name: "Lao People's Democratic Republic (+856)"},
{code2: 'LV', name: 'Latvia (+371)'},
{code2: 'LB', name: 'Lebanon (+961)'},
{code2: 'LS', name: 'Lesotho (+266)'},
{code2: 'LR', name: 'Liberia (+231)'},
{code2: 'LY', name: 'Libya (+218)'},
{code2: 'LI', name: 'Liechtenstein (+423)'},
{code2: 'LT', name: 'Lithuania (+370)'},
{code2: 'LU', name: 'Luxembourg (+352)'},
{code2: 'MO', name: 'Macao (+853)'},
{code2: 'MG', name: 'Madagascar (+261)'},
{code2: 'MW', name: 'Malawi (+265)'},
{code2: 'MY', name: 'Malaysia (+60)'},
{code2: 'MV', name: 'Maldives (+960)'},
{code2: 'ML', name: 'Mali (+223)'},
{code2: 'MT', name: 'Malta (+356)'},
{code2: 'MH', name: 'Marshall Islands (+692)'},
{code2: 'MQ', name: 'Martinique (+596)'},
{code2: 'MR', name: 'Mauritania (+222)'},
{code2: 'MU', name: 'Mauritius (+230)'},
{code2: 'YT', name: 'Mayotte (+262)'},
{code2: 'MX', name: 'Mexico (+52)'},
{code2: 'FM', name: 'Micronesia (Federated States of) (+691)'},
{code2: 'MD', name: 'Moldova, Republic of (+373)'},
{code2: 'MC', name: 'Monaco (+377)'},
{code2: 'MN', name: 'Mongolia (+976)'},
{code2: 'ME', name: 'Montenegro (+382)'},
{code2: 'MS', name: 'Montserrat (+1)'},
{code2: 'MA', name: 'Morocco (+212)'},
{code2: 'MZ', name: 'Mozambique (+258)'},
{code2: 'MM', name: 'Myanmar (+95)'},
{code2: 'NA', name: 'Namibia (+264)'},
{code2: 'NR', name: 'Nauru (+674)'},
{code2: 'NP', name: 'Nepal (+977)'},
{code2: 'NL', name: 'Netherlands, Kingdom of the (+31)'},
{code2: 'NC', name: 'New Caledonia (+687)'},
{code2: 'NZ', name: 'New Zealand (+64)'},
{code2: 'NI', name: 'Nicaragua (+505)'},
{code2: 'NE', name: 'Niger (+227)'},
{code2: 'NG', name: 'Nigeria (+234)'},
{code2: 'NU', name: 'Niue (+683)'},
{code2: 'NF', name: 'Norfolk Island (+672)'},
{code2: 'MK', name: 'North Macedonia (+389)'},
{code2: 'MP', name: 'Northern Mariana Islands (+1)'},
{code2: 'NO', name: 'Norway (+47)'},
{code2: 'OM', name: 'Oman (+968)'},
{code2: 'PK', name: 'Pakistan (+92)'},
{code2: 'PW', name: 'Palau (+680)'},
{code2: 'PS', name: 'Palestine, State of (+970)'},
{code2: 'PA', name: 'Panama (+507)'},
{code2: 'PG', name: 'Papua New Guinea (+675)'},
{code2: 'PY', name: 'Paraguay (+595)'},
{code2: 'PE', name: 'Peru (+51)'},
{code2: 'PH', name: 'Philippines (+63)'},
{code2: 'PL', name: 'Poland (+48)'},
{code2: 'PT', name: 'Portugal (+351)'},
{code2: 'PR', name: 'Puerto Rico (+1)'},
{code2: 'QA', name: 'Qatar (+974)'},
{code2: 'RE', name: 'Réunion (+262)'},
{code2: 'RO', name: 'Romania (+40)'},
{code2: 'RU', name: 'Russian Federation (+7)'},
{code2: 'RW', name: 'Rwanda (+250)'},
{code2: 'BL', name: 'Saint Barthélemy (+590)'},
{code2: 'SH', name: 'Saint Helena, Ascension and Tristan da Cunha (+290)'},
{code2: 'KN', name: 'Saint Kitts and Nevis (+1)'},
{code2: 'LC', name: 'Saint Lucia (+1)'},
{code2: 'MF', name: 'Saint Martin (French part) (+590)'},
{code2: 'PM', name: 'Saint Pierre and Miquelon (+508)'},
{code2: 'VC', name: 'Saint Vincent and the Grenadines (+1)'},
{code2: 'WS', name: 'Samoa (+685)'},
{code2: 'SM', name: 'San Marino (+378)'},
{code2: 'ST', name: 'Sao Tome and Principe (+239)'},
{code2: 'SA', name: 'Saudi Arabia (+966)'},
{code2: 'SN', name: 'Senegal (+221)'},
{code2: 'RS', name: 'Serbia (+381)'},
{code2: 'SC', name: 'Seychelles (+248)'},
{code2: 'SL', name: 'Sierra Leone (+232)'},
{code2: 'SG', name: 'Singapore (+65)'},
{code2: 'SX', name: 'Sint Maarten (Dutch part) (+1)'},
{code2: 'SK', name: 'Slovakia (+421)'},
{code2: 'SI', name: 'Slovenia (+386)'},
{code2: 'SB', name: 'Solomon Islands (+677)'},
{code2: 'SO', name: 'Somalia (+252)'},
{code2: 'ZA', name: 'South Africa (+27)'},
{code2: 'SS', name: 'South Sudan (+211)'},
{code2: 'ES', name: 'Spain (+34)'},
{code2: 'LK', name: 'Sri Lanka (+94)'},
{code2: 'SD', name: 'Sudan (+249)'},
{code2: 'SR', name: 'Suriname (+597)'},
{code2: 'SJ', name: 'Svalbard and Jan Mayen (+47)'},
{code2: 'SE', name: 'Sweden (+46)'},
{code2: 'CH', name: 'Switzerland (+41)'},
{code2: 'SY', name: 'Syrian Arab Republic (+963)'},
{code2: 'TW', name: 'Taiwan, Province of China (+886)'},
{code2: 'TJ', name: 'Tajikistan (+992)'},
{code2: 'TZ', name: 'Tanzania, United Republic of (+255)'},
{code2: 'TH', name: 'Thailand (+66)'},
{code2: 'TL', name: 'Timor-Leste (+670)'},
{code2: 'TG', name: 'Togo (+228)'},
{code2: 'TK', name: 'Tokelau (+690)'},
{code2: 'TO', name: 'Tonga (+676)'},
{code2: 'TT', name: 'Trinidad and Tobago (+1)'},
{code2: 'TN', name: 'Tunisia (+216)'},
{code2: 'TR', name: 'Türkiye (+90)'},
{code2: 'TM', name: 'Turkmenistan (+993)'},
{code2: 'TC', name: 'Turks and Caicos Islands (+1)'},
{code2: 'TV', name: 'Tuvalu (+688)'},
{code2: 'UG', name: 'Uganda (+256)'},
{code2: 'UA', name: 'Ukraine (+380)'},
{code2: 'AE', name: 'United Arab Emirates (+971)'},
{code2: 'GB', name: 'United Kingdom of Great Britain and Northern Ireland (+44)'},
{code2: 'US', name: 'United States of America (+1)'},
{code2: 'UY', name: 'Uruguay (+598)'},
{code2: 'UZ', name: 'Uzbekistan (+998)'},
{code2: 'VU', name: 'Vanuatu (+678)'},
{code2: 'VE', name: 'Venezuela (Bolivarian Republic of) (+58)'},
{code2: 'VN', name: 'Viet Nam (+84)'},
{code2: 'VG', name: 'Virgin Islands (British) (+1)'},
{code2: 'VI', name: 'Virgin Islands (U.S.) (+1)'},
{code2: 'WF', name: 'Wallis and Futuna (+681)'},
{code2: 'EH', name: 'Western Sahara (+212)'},
{code2: 'YE', name: 'Yemen (+967)'},
{code2: 'ZM', name: 'Zambia (+260)'},
{code2: 'ZW', name: 'Zimbabwe (+263)'},
]
+4 -2
View File
@@ -68,11 +68,12 @@ export function parseEmbedPlayerFromUrl(
// youtube
if (urlp.hostname === 'youtu.be') {
const videoId = urlp.pathname.split('/')[1]
const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
if (videoId) {
return {
type: 'youtube_video',
source: 'youtube',
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1&start=${seek}`,
}
}
}
@@ -84,13 +85,14 @@ export function parseEmbedPlayerFromUrl(
const [_, page, shortVideoId] = urlp.pathname.split('/')
const videoId =
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
if (videoId) {
return {
type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
hideDetails: page === 'shorts' ? true : undefined,
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1&start=${seek}`,
}
}
}
+56 -54
View File
@@ -2,30 +2,32 @@ import {Platform} from 'react-native'
import type {Theme} from './ThemeContext'
import {colors} from './styles'
import {darkPalette, lightPalette} from '#/alf/themes'
export const defaultTheme: Theme = {
colorScheme: 'light',
palette: {
default: {
background: colors.white,
backgroundLight: colors.gray1,
text: colors.black,
textLight: colors.gray5,
textInverted: colors.white,
link: colors.blue3,
border: '#f0e9e9',
borderDark: '#e0d9d9',
icon: colors.gray4,
background: lightPalette.white,
backgroundLight: lightPalette.contrast_50,
text: lightPalette.black,
textLight: lightPalette.contrast_700,
textInverted: lightPalette.white,
link: lightPalette.primary_500,
border: lightPalette.contrast_100,
borderDark: lightPalette.contrast_200,
icon: lightPalette.contrast_500,
// non-standard
textVeryLight: colors.gray4,
replyLine: colors.gray2,
replyLineDot: colors.gray3,
unreadNotifBg: '#ebf6ff',
unreadNotifBorder: colors.blue1,
postCtrl: '#71768A',
brandText: '#0066FF',
emptyStateIcon: '#B6B6C9',
borderLinkHover: '#cac1c1',
textVeryLight: lightPalette.contrast_400,
replyLine: lightPalette.contrast_100,
replyLineDot: lightPalette.contrast_200,
unreadNotifBg: lightPalette.primary_25,
unreadNotifBorder: lightPalette.primary_100,
postCtrl: lightPalette.contrast_500,
brandText: lightPalette.primary_500,
emptyStateIcon: lightPalette.contrast_300,
borderLinkHover: lightPalette.contrast_300,
},
primary: {
background: colors.blue3,
@@ -50,15 +52,15 @@ export const defaultTheme: Theme = {
icon: colors.green4,
},
inverted: {
background: colors.black,
backgroundLight: colors.gray6,
text: colors.white,
textLight: colors.gray3,
textInverted: colors.black,
link: colors.blue2,
border: colors.gray3,
borderDark: colors.gray2,
icon: colors.gray5,
background: darkPalette.black,
backgroundLight: darkPalette.contrast_50,
text: darkPalette.white,
textLight: darkPalette.contrast_700,
textInverted: darkPalette.black,
link: darkPalette.primary_500,
border: darkPalette.contrast_100,
borderDark: darkPalette.contrast_200,
icon: darkPalette.contrast_500,
},
error: {
background: colors.red3,
@@ -292,26 +294,26 @@ export const darkTheme: Theme = {
palette: {
...defaultTheme.palette,
default: {
background: colors.black,
backgroundLight: colors.gray7,
text: colors.white,
textLight: colors.gray3,
textInverted: colors.black,
link: colors.blue3,
border: colors.gray7,
borderDark: colors.gray6,
icon: colors.gray4,
background: darkPalette.black,
backgroundLight: darkPalette.contrast_50,
text: darkPalette.white,
textLight: darkPalette.contrast_700,
textInverted: darkPalette.black,
link: darkPalette.primary_500,
border: darkPalette.contrast_100,
borderDark: darkPalette.contrast_200,
icon: darkPalette.contrast_500,
// non-standard
textVeryLight: colors.gray4,
replyLine: colors.gray5,
replyLineDot: colors.gray6,
unreadNotifBg: colors.blue7,
unreadNotifBorder: colors.blue6,
postCtrl: '#707489',
brandText: '#0085ff',
emptyStateIcon: colors.gray4,
borderLinkHover: colors.gray5,
textVeryLight: darkPalette.contrast_400,
replyLine: darkPalette.contrast_100,
replyLineDot: darkPalette.contrast_200,
unreadNotifBg: darkPalette.primary_975,
unreadNotifBorder: darkPalette.primary_900,
postCtrl: darkPalette.contrast_500,
brandText: darkPalette.primary_500,
emptyStateIcon: darkPalette.contrast_300,
borderLinkHover: darkPalette.contrast_300,
},
primary: {
...defaultTheme.palette.primary,
@@ -322,15 +324,15 @@ export const darkTheme: Theme = {
textInverted: colors.green2,
},
inverted: {
background: colors.white,
backgroundLight: colors.gray2,
text: colors.black,
textLight: colors.gray5,
textInverted: colors.white,
link: colors.blue3,
border: colors.gray3,
borderDark: colors.gray4,
icon: colors.gray1,
background: lightPalette.white,
backgroundLight: lightPalette.contrast_50,
text: lightPalette.black,
textLight: lightPalette.contrast_700,
textInverted: lightPalette.white,
link: lightPalette.primary_500,
border: lightPalette.contrast_100,
borderDark: lightPalette.contrast_200,
icon: lightPalette.contrast_500,
},
},
}
+10
View File
@@ -22,6 +22,14 @@ export function code3ToCode2(lang: string): string {
return lang
}
export function code3ToCode2Strict(lang: string): string | undefined {
if (lang.length === 3) {
return LANGUAGES_MAP_CODE3[lang]?.code2
}
return undefined
}
export function codeToLanguageName(lang: string): string {
const lang2 = code3ToCode2(lang)
return LANGUAGES_MAP_CODE2[lang2]?.name || lang
@@ -129,6 +137,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.pt_BR
case 'uk':
return AppLanguage.uk
case 'ca':
return AppLanguage.ca
default:
continue
}
+5
View File
@@ -13,6 +13,7 @@ import {messages as messagesJa} from '#/locale/locales/ja/messages'
import {messages as messagesKo} from '#/locale/locales/ko/messages'
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
import {messages as messagesUk} from '#/locale/locales/uk/messages'
import {messages as messagesCa} from '#/locale/locales/ca/messages'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages'
@@ -59,6 +60,10 @@ export async function dynamicActivate(locale: AppLanguage) {
i18n.loadAndActivate({locale, messages: messagesUk})
break
}
case AppLanguage.ca: {
i18n.loadAndActivate({locale, messages: messagesCa})
break
}
default: {
i18n.loadAndActivate({locale, messages: messagesEn})
break
+8 -1
View File
@@ -49,6 +49,10 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/uk/messages`)
break
}
case AppLanguage.ca: {
mod = await import(`./locales/ca/messages`)
break
}
default: {
mod = await import(`./locales/en/messages`)
break
@@ -62,6 +66,9 @@ export async function dynamicActivate(locale: AppLanguage) {
export async function useLocaleLanguage() {
const {appLanguage} = useLanguagePrefs()
useEffect(() => {
dynamicActivate(sanitizeAppLanguageSetting(appLanguage))
const sanitizedLanguage = sanitizeAppLanguageSetting(appLanguage)
document.documentElement.lang = sanitizedLanguage
dynamicActivate(sanitizedLanguage)
}, [appLanguage])
}
+2
View File
@@ -16,6 +16,7 @@ export enum AppLanguage {
ko = 'ko',
pt_BR = 'pt-BR',
uk = 'uk',
ca = 'ca',
}
interface AppLanguageConfig {
@@ -35,6 +36,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.ko, name: '한국어'},
{code2: AppLanguage.pt_BR, name: 'Português (BR)'},
{code2: AppLanguage.uk, name: 'Українська'},
{code2: AppLanguage.ca, name: 'Catalan'},
]
export const LANGUAGES: Language[] = [
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import React from 'react'
import {DialogControlProps} from '#/components/Dialog'
const DialogContext = React.createContext<{
activeDialogs: React.MutableRefObject<
Map<string, React.MutableRefObject<DialogControlProps>>
>
}>({
activeDialogs: {
current: new Map(),
},
})
const DialogControlContext = React.createContext<{
closeAllDialogs(): void
}>({
closeAllDialogs: () => {},
})
export function useDialogStateContext() {
return React.useContext(DialogContext)
}
export function useDialogStateControlContext() {
return React.useContext(DialogControlContext)
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const activeDialogs = React.useRef<
Map<string, React.MutableRefObject<DialogControlProps>>
>(new Map())
const closeAllDialogs = React.useCallback(() => {
activeDialogs.current.forEach(dialog => dialog.current.close())
}, [])
const context = React.useMemo(() => ({activeDialogs}), [])
const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs])
return (
<DialogContext.Provider value={context}>
<DialogControlContext.Provider value={controls}>
{children}
</DialogControlContext.Provider>
</DialogContext.Provider>
)
}
+6
View File
@@ -187,6 +187,11 @@ export interface EmbedConsentModal {
onAccept: () => void
}
export interface InAppBrowserConsentModal {
name: 'in-app-browser-consent'
href: string
}
export type Modal =
// Account
| AddAppPasswordModal
@@ -231,6 +236,7 @@ export type Modal =
| ConfirmModal
| LinkWarningModal
| EmbedConsentModal
| InAppBrowserConsentModal
const ModalContext = React.createContext<{
isModalActive: boolean
@@ -26,7 +26,7 @@ test('migrate: fresh install', async () => {
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
expect(read).toHaveBeenCalledTimes(1)
expect(logger.log).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
'persisted state: no migration needed',
)
})
@@ -38,7 +38,7 @@ test('migrate: fresh install, existing new storage', async () => {
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
expect(read).toHaveBeenCalledTimes(1)
expect(logger.log).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
'persisted state: no migration needed',
)
})
@@ -68,7 +68,7 @@ test('migrate: has legacy data', async () => {
await migrate()
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
expect(logger.log).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
'persisted state: migrated legacy storage',
)
})
+2 -2
View File
@@ -164,14 +164,14 @@ export async function migrate() {
if (validate.success) {
await write(newData)
logger.log('persisted state: migrated legacy storage')
logger.info('persisted state: migrated legacy storage')
} else {
logger.error('persisted state: legacy data failed validation', {
error: validate.error,
})
}
} else {
logger.log('persisted state: no migration needed')
logger.info('persisted state: no migration needed')
}
} catch (e: any) {
logger.error(e, {
+2
View File
@@ -53,6 +53,7 @@ export const schema = z.object({
step: z.string(),
}),
hiddenPosts: z.array(z.string()).optional(), // should move to server
useInAppBrowser: z.boolean().optional(),
})
export type Schema = z.infer<typeof schema>
@@ -84,4 +85,5 @@ export const defaults: Schema = {
step: 'Home',
},
hiddenPosts: [],
useInAppBrowser: undefined,
}
+79
View File
@@ -0,0 +1,79 @@
import React from 'react'
import * as persisted from '#/state/persisted'
import {Linking} from 'react-native'
import * as WebBrowser from 'expo-web-browser'
import {isNative} from '#/platform/detection'
import {useModalControls} from '../modals'
type StateContext = persisted.Schema['useInAppBrowser']
type SetContext = (v: persisted.Schema['useInAppBrowser']) => void
const stateContext = React.createContext<StateContext>(
persisted.defaults.useInAppBrowser,
)
const setContext = React.createContext<SetContext>(
(_: persisted.Schema['useInAppBrowser']) => {},
)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('useInAppBrowser'))
const setStateWrapped = React.useCallback(
(inAppBrowser: persisted.Schema['useInAppBrowser']) => {
setState(inAppBrowser)
persisted.write('useInAppBrowser', inAppBrowser)
},
[setState],
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('useInAppBrowser'))
})
}, [setStateWrapped])
return (
<stateContext.Provider value={state}>
<setContext.Provider value={setStateWrapped}>
{children}
</setContext.Provider>
</stateContext.Provider>
)
}
export function useInAppBrowser() {
return React.useContext(stateContext)
}
export function useSetInAppBrowser() {
return React.useContext(setContext)
}
export function useOpenLink() {
const {openModal} = useModalControls()
const enabled = useInAppBrowser()
const openLink = React.useCallback(
(url: string, override?: boolean) => {
if (isNative && !url.startsWith('mailto:')) {
if (override === undefined && enabled === undefined) {
openModal({
name: 'in-app-browser-consent',
href: url,
})
return
} else if (override ?? enabled) {
WebBrowser.openBrowserAsync(url, {
presentationStyle:
WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN,
})
return
}
}
Linking.openURL(url)
},
[enabled, openModal],
)
return openLink
}
+4 -1
View File
@@ -3,6 +3,7 @@ import {Provider as LanguagesProvider} from './languages'
import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
import {Provider as InAppBrowserProvider} from './in-app-browser'
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
export {
@@ -20,7 +21,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
<LanguagesProvider>
<AltTextRequiredProvider>
<ExternalEmbedsProvider>
<HiddenPostsProvider>{children}</HiddenPostsProvider>
<HiddenPostsProvider>
<InAppBrowserProvider>{children}</InAppBrowserProvider>
</HiddenPostsProvider>
</ExternalEmbedsProvider>
</AltTextRequiredProvider>
</LanguagesProvider>
+2 -1
View File
@@ -272,7 +272,8 @@ export function usePinnedFeedsInfos(): {
},
})
} catch (e) {
logger.warn(`usePinnedFeedsInfos: failed to fetch ${uri}`, {
// expected failure
logger.info(`usePinnedFeedsInfos: failed to fetch ${uri}`, {
error: e,
})
}
+1 -1
View File
@@ -167,7 +167,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
broadcast.postMessage({event: unreadCountStr})
} catch (e) {
logger.error('Failed to check unread notifications', {error: e})
logger.warn('Failed to check unread notifications', {error: e})
}
},
+6 -1
View File
@@ -18,6 +18,7 @@ import {LikesFeedAPI} from 'lib/api/feed/likes'
import {CustomFeedAPI} from 'lib/api/feed/custom'
import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge'
import {HomeFeedAPI} from '#/lib/api/feed/home'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri'
@@ -338,7 +339,11 @@ function createApi(
feedTuners: FeedTunerFn[],
) {
if (feedDesc === 'home') {
return new MergeFeedAPI(params, feedTuners)
if (params.mergeFeedEnabled) {
return new MergeFeedAPI(params, feedTuners)
} else {
return new HomeFeedAPI()
}
} else if (feedDesc === 'following') {
return new FollowingFeedAPI()
} else if (feedDesc.startsWith('author')) {
+56 -24
View File
@@ -44,6 +44,8 @@ export type ApiContext = {
password: string
handle: string
inviteCode?: string
verificationPhone?: string
verificationCode?: string
}) => Promise<void>
login: (props: {
service: string
@@ -193,11 +195,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
const clearCurrentAccount = React.useCallback(() => {
logger.debug(
`session: clear current account`,
{},
logger.DebugContext.session,
)
logger.warn(`session: clear current account`)
__globalAgent = PUBLIC_BSKY_AGENT
queryClient.clear()
setStateAndPersist(s => ({
@@ -207,7 +205,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}, [setStateAndPersist, queryClient])
const createAccount = React.useCallback<ApiContext['createAccount']>(
async ({service, email, password, handle, inviteCode}: any) => {
async ({
service,
email,
password,
handle,
inviteCode,
verificationPhone,
verificationCode,
}: any) => {
logger.info(`session: creating account`, {
service,
handle,
@@ -221,12 +227,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
password,
email,
inviteCode,
verificationPhone,
verificationCode,
})
if (!agent.session) {
throw new Error(`session: createAccount failed to establish a session`)
}
/*dont await*/ agent.upsertProfile(_existing => {
return {
displayName: handle,
}
})
const account: SessionAccount = {
service: agent.service.toString(),
did: agent.session.did,
@@ -322,8 +336,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
const logout = React.useCallback<ApiContext['logout']>(async () => {
logger.info(`session: logout`)
clearCurrentAccount()
logger.debug(`session: logout`, {}, logger.DebugContext.session)
setStateAndPersist(s => {
return {
...s,
@@ -551,30 +565,36 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return persisted.onUpdate(() => {
const session = persisted.get('session')
logger.debug(`session: onUpdate`, {}, logger.DebugContext.session)
logger.info(`session: persisted onUpdate`, {})
if (session.currentAccount) {
if (session.currentAccount && session.currentAccount.refreshJwt) {
if (session.currentAccount?.did !== state.currentAccount?.did) {
logger.debug(
`session: switching account`,
{
from: {
did: state.currentAccount?.did,
handle: state.currentAccount?.handle,
},
to: {
did: session.currentAccount.did,
handle: session.currentAccount.handle,
},
logger.info(`session: persisted onUpdate, switching accounts`, {
from: {
did: state.currentAccount?.did,
handle: state.currentAccount?.handle,
},
logger.DebugContext.session,
)
to: {
did: session.currentAccount.did,
handle: session.currentAccount.handle,
},
})
initSession(session.currentAccount)
} else {
logger.info(`session: persisted onUpdate, updating session`, {})
/*
* Use updated session in this tab's agent. Do not call
* upsertAccount, since that will only persist the session that's
* already persisted, and we'll get a loop between tabs.
*/
// @ts-ignore we checked for `refreshJwt` above
__globalAgent.session = session.currentAccount
}
} else if (!session.currentAccount && state.currentAccount) {
logger.debug(
`session: logging out`,
`session: persisted onUpdate, logging out`,
{
did: state.currentAccount?.did,
handle: state.currentAccount?.handle,
@@ -582,10 +602,22 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
logger.DebugContext.session,
)
/*
* No need to do a hard logout here. If we reach this, tokens for this
* account have already been cleared either by an `expired` event
* handled by `persistSession` (which nukes this accounts tokens only),
* or by a `logout` call which nukes all accounts tokens)
*/
clearCurrentAccount()
}
setState(s => ({
...s,
accounts: session.accounts,
currentAccount: session.currentAccount,
}))
})
}, [state, clearCurrentAccount, initSession])
}, [state, setState, clearCurrentAccount, initSession])
const stateContext = React.useMemo(
() => ({
+2 -1
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {AppBskyEmbedRecord} from '@atproto/api'
import {AppBskyEmbedRecord, AppBskyRichtextFacet} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
export interface ComposerOptsPostRef {
@@ -17,6 +17,7 @@ export interface ComposerOptsQuote {
uri: string
cid: string
text: string
facets?: AppBskyRichtextFacet.Main[]
indexedAt: string
author: {
did: string
-204
View File
@@ -1,204 +0,0 @@
import React from 'react'
import {Pressable, Text, PressableProps, TextProps} from 'react-native'
import * as tokens from '#/alf/tokens'
import {atoms} from '#/alf'
export type ButtonType =
| 'primary'
| 'secondary'
| 'tertiary'
| 'positive'
| 'negative'
export type ButtonSize = 'small' | 'large'
export type VariantProps = {
type?: ButtonType
size?: ButtonSize
}
type ButtonState = {
pressed: boolean
hovered: boolean
focused: boolean
}
export type ButtonProps = Omit<PressableProps, 'children'> &
VariantProps & {
children:
| ((props: {
state: ButtonState
type?: ButtonType
size?: ButtonSize
}) => React.ReactNode)
| React.ReactNode
| string
}
export type ButtonTextProps = TextProps & VariantProps
export function Button({children, style, type, size, ...rest}: ButtonProps) {
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles = []
const hoverStyles = []
switch (type) {
case 'primary':
baseStyles.push({
backgroundColor: tokens.color.blue_500,
})
break
case 'secondary':
baseStyles.push({
backgroundColor: tokens.color.gray_200,
})
hoverStyles.push({
backgroundColor: tokens.color.gray_100,
})
break
default:
}
switch (size) {
case 'large':
baseStyles.push(
atoms.py_md,
atoms.px_xl,
atoms.rounded_md,
atoms.gap_sm,
)
break
case 'small':
baseStyles.push(
atoms.py_sm,
atoms.px_md,
atoms.rounded_sm,
atoms.gap_xs,
)
break
default:
}
return {
baseStyles,
hoverStyles,
}
}, [type, size])
const [state, setState] = React.useState({
pressed: false,
hovered: false,
focused: false,
})
const onPressIn = React.useCallback(() => {
setState(s => ({
...s,
pressed: true,
}))
}, [setState])
const onPressOut = React.useCallback(() => {
setState(s => ({
...s,
pressed: false,
}))
}, [setState])
const onHoverIn = React.useCallback(() => {
setState(s => ({
...s,
hovered: true,
}))
}, [setState])
const onHoverOut = React.useCallback(() => {
setState(s => ({
...s,
hovered: false,
}))
}, [setState])
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
focused: true,
}))
}, [setState])
const onBlur = React.useCallback(() => {
setState(s => ({
...s,
focused: false,
}))
}, [setState])
return (
<Pressable
{...rest}
style={state => [
atoms.flex_row,
atoms.align_center,
...baseStyles,
...(state.hovered ? hoverStyles : []),
typeof style === 'function' ? style(state) : style,
]}
onPressIn={onPressIn}
onPressOut={onPressOut}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
onFocus={onFocus}
onBlur={onBlur}>
{typeof children === 'string' ? (
<ButtonText type={type} size={size}>
{children}
</ButtonText>
) : typeof children === 'function' ? (
children({state, type, size})
) : (
children
)}
</Pressable>
)
}
export function ButtonText({
children,
style,
type,
size,
...rest
}: ButtonTextProps) {
const textStyles = React.useMemo(() => {
const base = []
switch (type) {
case 'primary':
base.push({color: tokens.color.white})
break
case 'secondary':
base.push({
color: tokens.color.gray_700,
})
break
default:
}
switch (size) {
case 'small':
base.push(atoms.text_sm, {paddingBottom: 1})
break
case 'large':
base.push(atoms.text_md, {paddingBottom: 1})
break
default:
}
return base
}, [type, size])
return (
<Text
{...rest}
style={[
atoms.flex_1,
atoms.font_semibold,
atoms.text_center,
...textStyles,
style,
]}>
{children}
</Text>
)
}
+24 -2
View File
@@ -22,12 +22,13 @@ import {
useSetSaveFeedsMutation,
DEFAULT_PROD_FEEDS,
} from '#/state/queries/preferences'
import {IS_PROD} from '#/lib/constants'
import {FEEDBACK_FORM_URL, IS_PROD} from '#/lib/constants'
import {Step1} from './Step1'
import {Step2} from './Step2'
import {Step3} from './Step3'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {TextLink} from '../../util/Link'
export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
const {screen} = useAnalytics()
@@ -117,7 +118,7 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
return (
<LoggedOutLayout
leadin={`Step ${uiState.step}`}
leadin=""
title={_(msg`Create Account`)}
description={_(msg`We're so excited to have you join us!`)}>
<ScrollView testID="createAccount" style={pal.view}>
@@ -176,6 +177,27 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
</>
) : undefined}
</View>
<View style={styles.stepContainer}>
<View
style={[
s.flexRow,
s.alignCenter,
pal.viewLight,
{borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12},
]}>
<Text type="md" style={pal.textLight}>
<Trans>Having trouble?</Trans>{' '}
</Text>
<TextLink
type="md"
style={pal.link}
text={_(msg`Contact support`)}
href={FEEDBACK_FORM_URL({email: uiState.email})}
/>
</View>
</View>
<View style={{height: isTabletOrDesktop ? 50 : 400}} />
</ScrollView>
</LoggedOutLayout>
+189 -156
View File
@@ -1,25 +1,38 @@
import React from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import {
ActivityIndicator,
Keyboard,
StyleSheet,
TouchableWithoutFeedback,
View,
} from 'react-native'
import {CreateAccountState, CreateAccountDispatch, is18} from './state'
import {Text} from 'view/com/util/text/Text'
import {DateInput} from 'view/com/util/forms/DateInput'
import {StepHeader} from './StepHeader'
import {CreateAccountState, CreateAccountDispatch} from './state'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {HelpTip} from '../util/HelpTip'
import {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput'
import {Button} from 'view/com/util/forms/Button'
import {Button} from '../../util/forms/Button'
import {Policies} from './Policies'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {msg, Trans} from '@lingui/macro'
import {isWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {logger} from '#/logger'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants'
import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
function sanitizeDate(date: Date): Date {
if (!date || date.toString() === 'Invalid Date') {
logger.error(`Create account: handled invalid date for birthDate`, {
hasDate: !!date,
})
return new Date()
}
return date
}
/** STEP 1: Your hosting provider
* @field Bluesky (default)
* @field Other (staging, local dev, your own PDS, etc.)
*/
export function Step1({
uiState,
uiDispatch,
@@ -28,136 +41,175 @@ export function Step1({
uiDispatch: CreateAccountDispatch
}) {
const pal = usePalette('default')
const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
const {_} = useLingui()
const {openModal} = useModalControls()
const onPressDefault = React.useCallback(() => {
setIsDefaultSelected(true)
uiDispatch({type: 'set-service-url', value: PROD_SERVICE})
}, [setIsDefaultSelected, uiDispatch])
const onPressSelectService = React.useCallback(() => {
openModal({
name: 'server-input',
initialService: uiState.serviceUrl,
onSelect: (url: string) =>
uiDispatch({type: 'set-service-url', value: url}),
})
Keyboard.dismiss()
}, [uiDispatch, uiState.serviceUrl, openModal])
const onPressOther = React.useCallback(() => {
setIsDefaultSelected(false)
uiDispatch({type: 'set-service-url', value: 'https://'})
}, [setIsDefaultSelected, uiDispatch])
const onPressWaitlist = React.useCallback(() => {
openModal({name: 'waitlist'})
}, [openModal])
const onChangeServiceUrl = React.useCallback(
(v: string) => {
uiDispatch({type: 'set-service-url', value: v})
},
[uiDispatch],
)
const birthDate = React.useMemo(() => {
return sanitizeDate(uiState.birthDate)
}, [uiState.birthDate])
return (
<View>
<StepHeader step="1" title={_(msg`Your hosting provider`)} />
<Text style={[pal.text, s.mb10]}>
<Trans>This is the service that keeps you online.</Trans>
</Text>
<Option
testID="blueskyServerBtn"
isSelected={isDefaultSelected}
label="Bluesky"
help="&nbsp;(default)"
onPress={onPressDefault}
/>
<Option
testID="otherServerBtn"
isSelected={!isDefaultSelected}
label="Other"
onPress={onPressOther}>
<View style={styles.otherForm}>
<Text nativeID="addressProvider" style={[pal.text, s.mb5]}>
<Trans>Enter the address of your provider:</Trans>
</Text>
<TextInput
testID="customServerInput"
icon="globe"
placeholder={_(msg`Hosting provider address`)}
value={uiState.serviceUrl}
editable
onChange={onChangeServiceUrl}
accessibilityHint={_(msg`Input hosting provider address`)}
accessibilityLabel={_(msg`Hosting provider address`)}
accessibilityLabelledBy="addressProvider"
/>
{LOGIN_INCLUDE_DEV_SERVERS && (
<View style={[s.flexRow, s.mt10]}>
<Button
testID="stagingServerBtn"
type="default"
style={s.mr5}
label={_(msg`Staging`)}
onPress={() => onChangeServiceUrl(STAGING_SERVICE)}
/>
<Button
testID="localDevServerBtn"
type="default"
label={_(msg`Dev Server`)}
onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)}
<StepHeader uiState={uiState} title={_(msg`Your account`)}>
<View>
<Button
testID="selectServiceButton"
type="default"
style={{
aspectRatio: 1,
justifyContent: 'center',
alignItems: 'center',
}}
accessibilityLabel={_(msg`Select service`)}
accessibilityHint={_(msg`Sets server for the Bluesky client`)}
onPress={onPressSelectService}>
<FontAwesomeIcon icon="server" size={21} />
</Button>
</View>
</StepHeader>
{!uiState.serviceDescription ? (
<ActivityIndicator />
) : (
<>
{uiState.isInviteCodeRequired && (
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
<Trans>Invite code</Trans>
</Text>
<TextInput
testID="inviteCodeInput"
icon="ticket"
placeholder={_(msg`Required for this provider`)}
value={uiState.inviteCode}
editable
onChange={value => uiDispatch({type: 'set-invite-code', value})}
accessibilityLabel={_(msg`Invite code`)}
accessibilityHint={_(msg`Input invite code to proceed`)}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
autoFocus={true}
/>
</View>
)}
</View>
</Option>
{!uiState.inviteCode && uiState.isInviteCodeRequired ? (
<View style={[s.flexRow, s.alignCenter]}>
<Text style={pal.text}>
<Trans>Don't have an invite code?</Trans>{' '}
</Text>
<TouchableWithoutFeedback
onPress={onPressWaitlist}
accessibilityLabel={_(msg`Join the waitlist.`)}
accessibilityHint="">
<View style={styles.touchable}>
<Text style={pal.link}>
<Trans>Join the waitlist.</Trans>
</Text>
</View>
</TouchableWithoutFeedback>
</View>
) : (
<>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="email">
<Trans>Email address</Trans>
</Text>
<TextInput
testID="emailInput"
icon="envelope"
placeholder={_(msg`Enter your email address`)}
value={uiState.email}
editable
onChange={value => uiDispatch({type: 'set-email', value})}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(msg`Input email for Bluesky account`)}
accessibilityLabelledBy="email"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
autoFocus={!uiState.isInviteCodeRequired}
/>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="password">
<Trans>Password</Trans>
</Text>
<TextInput
testID="passwordInput"
icon="lock"
placeholder={_(msg`Choose your password`)}
value={uiState.password}
editable
secureTextEntry
onChange={value => uiDispatch({type: 'set-password', value})}
accessibilityLabel={_(msg`Password`)}
accessibilityHint={_(msg`Set password`)}
accessibilityLabelledBy="password"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="birthDate">
<Trans>Your birth date</Trans>
</Text>
<DateInput
handleAsUTC
testID="birthdayInput"
value={birthDate}
onChange={value =>
uiDispatch({type: 'set-birth-date', value})
}
buttonType="default-light"
buttonStyle={[pal.border, styles.dateInputButton]}
buttonLabelType="lg"
accessibilityLabel={_(msg`Birthday`)}
accessibilityHint={_(msg`Enter your birth date`)}
accessibilityLabelledBy="birthDate"
/>
</View>
{uiState.serviceDescription && (
<Policies
serviceDescription={uiState.serviceDescription}
needsGuardian={!is18(uiState)}
/>
)}
</>
)}
</>
)}
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : (
<HelpTip text={_(msg`You can change hosting providers at any time.`)} />
)}
</View>
)
}
function Option({
children,
isSelected,
label,
help,
onPress,
testID,
}: React.PropsWithChildren<{
isSelected: boolean
label: string
help?: string
onPress: () => void
testID?: string
}>) {
const theme = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
const circleFillStyle = React.useMemo(
() => ({
backgroundColor: theme.palette.primary.background,
}),
[theme],
)
return (
<View style={[styles.option, pal.border]}>
<TouchableWithoutFeedback
onPress={onPress}
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint={_(msg`Sets hosting provider to ${label}`)}>
<View style={styles.optionHeading}>
<View style={[styles.circle, pal.border]}>
{isSelected ? (
<View style={[circleFillStyle, styles.circleFill]} />
) : undefined}
</View>
<Text type="xl" style={pal.text}>
{label}
{help ? (
<Text type="xl" style={pal.textLight}>
{help}
</Text>
) : undefined}
</Text>
</View>
</TouchableWithoutFeedback>
{isSelected && children}
) : undefined}
</View>
)
}
@@ -165,34 +217,15 @@ function Option({
const styles = StyleSheet.create({
error: {
borderRadius: 6,
marginTop: 10,
},
option: {
dateInputButton: {
borderWidth: 1,
borderRadius: 6,
marginBottom: 10,
paddingVertical: 14,
},
optionHeading: {
flexDirection: 'row',
alignItems: 'center',
padding: 10,
},
circle: {
width: 26,
height: 26,
borderRadius: 15,
padding: 4,
borderWidth: 1,
marginRight: 10,
},
circleFill: {
width: 16,
height: 16,
borderRadius: 10,
},
otherForm: {
paddingBottom: 10,
paddingHorizontal: 12,
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
touchable: {
...(isWeb && {cursor: 'pointer'}),
},
})
+248 -135
View File
@@ -1,39 +1,34 @@
import React from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import {CreateAccountState, CreateAccountDispatch, is18} from './state'
import {
ActivityIndicator,
StyleSheet,
TouchableWithoutFeedback,
View,
} from 'react-native'
import RNPickerSelect from 'react-native-picker-select'
import {
CreateAccountState,
CreateAccountDispatch,
requestVerificationCode,
} from './state'
import {Text} from 'view/com/util/text/Text'
import {DateInput} from 'view/com/util/forms/DateInput'
import {StepHeader} from './StepHeader'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput'
import {Policies} from './Policies'
import {Button} from '../../util/forms/Button'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {isWeb} from 'platform/detection'
import {isAndroid, isWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {logger} from '#/logger'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import parsePhoneNumber from 'libphonenumber-js'
import {COUNTRY_CODES} from '#/lib/country-codes'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
function sanitizeDate(date: Date): Date {
if (!date || date.toString() === 'Invalid Date') {
logger.error(`Create account: handled invalid date for birthDate`, {
hasDate: !!date,
})
return new Date()
}
return date
}
/** STEP 2: Your account
* @field Invite code or waitlist
* @field Email address
* @field Email address
* @field Email address
* @field Password
* @field Birth date
* @readonly Terms of service & privacy policy
*/
export function Step2({
uiState,
uiDispatch,
@@ -43,130 +38,253 @@ export function Step2({
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {openModal} = useModalControls()
const {isMobile} = useWebMediaQueries()
const onPressWaitlist = React.useCallback(() => {
openModal({name: 'waitlist'})
}, [openModal])
const onPressRequest = React.useCallback(() => {
if (
uiState.verificationPhone.length >= 9 &&
parsePhoneNumber(uiState.verificationPhone, uiState.phoneCountry)
) {
requestVerificationCode({uiState, uiDispatch, _})
} else {
uiDispatch({
type: 'set-error',
value: _(
msg`There's something wrong with this number. Please choose your country and enter your full phone number!`,
),
})
}
}, [uiState, uiDispatch, _])
const birthDate = React.useMemo(() => {
return sanitizeDate(uiState.birthDate)
}, [uiState.birthDate])
const onPressRetry = React.useCallback(() => {
uiDispatch({type: 'set-has-requested-verification-code', value: false})
}, [uiDispatch])
const phoneNumberFormatted = React.useMemo(
() =>
uiState.hasRequestedVerificationCode
? parsePhoneNumber(
uiState.verificationPhone,
uiState.phoneCountry,
)?.formatInternational()
: '',
[
uiState.hasRequestedVerificationCode,
uiState.verificationPhone,
uiState.phoneCountry,
],
)
return (
<View>
<StepHeader step="2" title={_(msg`Your account`)} />
<StepHeader uiState={uiState} title={_(msg`SMS verification`)} />
{uiState.isInviteCodeRequired && (
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
<Trans>Invite code</Trans>
</Text>
<TextInput
testID="inviteCodeInput"
icon="ticket"
placeholder={_(msg`Required for this provider`)}
value={uiState.inviteCode}
editable
onChange={value => uiDispatch({type: 'set-invite-code', value})}
accessibilityLabel={_(msg`Invite code`)}
accessibilityHint={_(msg`Input invite code to proceed`)}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
)}
{!uiState.inviteCode && uiState.isInviteCodeRequired ? (
<Text style={[s.alignBaseline, pal.text]}>
<Trans>Don't have an invite code?</Trans>{' '}
<TouchableWithoutFeedback
onPress={onPressWaitlist}
accessibilityLabel={_(msg`Join the waitlist.`)}
accessibilityHint="">
<View style={styles.touchable}>
<Text style={pal.link}>
<Trans>Join the waitlist.</Trans>
</Text>
{!uiState.hasRequestedVerificationCode ? (
<>
<View style={s.pb10}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="phoneCountry">
<Trans>Country</Trans>
</Text>
<View
style={[
{position: 'relative'},
isAndroid && {
borderWidth: 1,
borderColor: pal.border.borderColor,
borderRadius: 4,
},
]}>
<RNPickerSelect
placeholder={{}}
value={uiState.phoneCountry}
onValueChange={value =>
uiDispatch({type: 'set-phone-country', value})
}
items={COUNTRY_CODES.filter(l => Boolean(l.code2)).map(l => ({
label: l.name,
value: l.code2,
key: l.code2,
}))}
style={{
inputAndroid: {
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 21,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 4,
},
inputIOS: {
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderWidth: 1,
borderColor: pal.border.borderColor,
borderRadius: 4,
},
inputWeb: {
// @ts-ignore web only
cursor: 'pointer',
'-moz-appearance': 'none',
'-webkit-appearance': 'none',
appearance: 'none',
outline: 0,
borderWidth: 1,
borderColor: pal.border.borderColor,
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 4,
},
}}
accessibilityLabel={_(msg`Select your phone's country`)}
accessibilityHint=""
accessibilityLabelledBy="phoneCountry"
/>
<View
style={{
position: 'absolute',
top: 1,
right: 1,
bottom: 1,
width: 40,
pointerEvents: 'none',
alignItems: 'center',
justifyContent: 'center',
}}>
<FontAwesomeIcon
icon="chevron-down"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
</View>
</TouchableWithoutFeedback>
</Text>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="phoneNumber">
<Trans>Phone number</Trans>
</Text>
<TextInput
testID="phoneInput"
icon="phone"
placeholder={_(msg`Enter your phone number`)}
value={uiState.verificationPhone}
editable
onChange={value =>
uiDispatch({type: 'set-verification-phone', value})
}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(
msg`Input phone number for SMS verification`,
)}
accessibilityLabelledBy="phoneNumber"
keyboardType="phone-pad"
autoCapitalize="none"
autoComplete="tel"
autoCorrect={false}
autoFocus={true}
/>
<Text type="sm" style={[pal.textLight, s.mt5]}>
<Trans>
Please enter a phone number that can receive SMS text messages.
</Trans>
</Text>
</View>
<View style={isMobile ? {} : {flexDirection: 'row'}}>
{uiState.isProcessing ? (
<ActivityIndicator />
) : (
<Button
testID="requestCodeBtn"
type="primary"
label={_(msg`Request code`)}
labelStyle={isMobile ? [s.flex1, s.textCenter, s.f17] : []}
style={
isMobile ? {paddingVertical: 12, paddingHorizontal: 20} : {}
}
onPress={onPressRequest}
/>
)}
</View>
</>
) : (
<>
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]} nativeID="email">
<Trans>Email address</Trans>
</Text>
<View
style={[
s.flexRow,
s.mb5,
s.alignCenter,
{justifyContent: 'space-between'},
]}>
<Text
type="md-medium"
style={pal.text}
nativeID="verificationCode">
<Trans>Verification code</Trans>{' '}
</Text>
<TouchableWithoutFeedback
onPress={onPressRetry}
accessibilityLabel={_(msg`Retry.`)}
accessibilityHint="">
<View style={styles.touchable}>
<Text
type="md-medium"
style={pal.link}
nativeID="verificationCode">
<Trans>Retry</Trans>
</Text>
</View>
</TouchableWithoutFeedback>
</View>
<TextInput
testID="emailInput"
icon="envelope"
placeholder={_(msg`Enter your email address`)}
value={uiState.email}
testID="codeInput"
icon="hashtag"
placeholder={_(msg`XXXXXX`)}
value={uiState.verificationCode}
editable
onChange={value => uiDispatch({type: 'set-email', value})}
onChange={value =>
uiDispatch({type: 'set-verification-code', value})
}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(msg`Input email for Bluesky waitlist`)}
accessibilityLabelledBy="email"
accessibilityHint={_(
msg`Input the verification code we have texted to you`,
)}
accessibilityLabelledBy="verificationCode"
keyboardType="phone-pad"
autoCapitalize="none"
autoComplete="off"
autoComplete="one-time-code"
textContentType="oneTimeCode"
autoCorrect={false}
autoFocus={true}
/>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="password">
<Trans>Password</Trans>
<Text type="sm" style={[pal.textLight, s.mt5]}>
<Trans>
Please enter the verification code sent to{' '}
{phoneNumberFormatted}.
</Trans>
</Text>
<TextInput
testID="passwordInput"
icon="lock"
placeholder={_(msg`Choose your password`)}
value={uiState.password}
editable
secureTextEntry
onChange={value => uiDispatch({type: 'set-password', value})}
accessibilityLabel={_(msg`Password`)}
accessibilityHint={_(msg`Set password`)}
accessibilityLabelledBy="password"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="birthDate">
<Trans>Your birth date</Trans>
</Text>
<DateInput
handleAsUTC
testID="birthdayInput"
value={birthDate}
onChange={value => uiDispatch({type: 'set-birth-date', value})}
buttonType="default-light"
buttonStyle={[pal.border, styles.dateInputButton]}
buttonLabelType="lg"
accessibilityLabel={_(msg`Birthday`)}
accessibilityHint={_(msg`Enter your birth date`)}
accessibilityLabelledBy="birthDate"
/>
</View>
{uiState.serviceDescription && (
<Policies
serviceDescription={uiState.serviceDescription}
needsGuardian={!is18(uiState)}
/>
)}
</>
)}
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
@@ -179,11 +297,6 @@ const styles = StyleSheet.create({
borderRadius: 6,
marginTop: 10,
},
dateInputButton: {
borderWidth: 1,
borderRadius: 6,
paddingVertical: 14,
},
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
touchable: {
...(isWeb && {cursor: 'pointer'}),
+1 -1
View File
@@ -25,7 +25,7 @@ export function Step3({
const {_} = useLingui()
return (
<View>
<StepHeader step="3" title={_(msg`Your user handle`)} />
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
<View style={s.pb10}>
<TextInput
testID="handleInput"
+26 -11
View File
@@ -3,27 +3,42 @@ import {StyleSheet, View} from 'react-native'
import {Text} from 'view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {Trans} from '@lingui/macro'
import {CreateAccountState} from './state'
export function StepHeader({step, title}: {step: string; title: string}) {
export function StepHeader({
uiState,
title,
children,
}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) {
const pal = usePalette('default')
const numSteps = uiState.isPhoneVerificationRequired ? 3 : 2
return (
<View style={styles.container}>
<Text type="lg" style={[pal.textLight]}>
{step === '3' ? (
<Trans>Last step!</Trans>
) : (
<Trans>Step {step} of 3</Trans>
)}
</Text>
<Text style={[pal.text]} type="title-xl">
{title}
</Text>
<View>
<Text type="lg" style={[pal.textLight]}>
{uiState.step === 3 ? (
<Trans>Last step!</Trans>
) : (
<Trans>
Step {uiState.step} of {numSteps}
</Trans>
)}
</Text>
<Text style={[pal.text]} type="title-xl">
{title}
</Text>
</View>
{children}
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
})
+108 -8
View File
@@ -2,6 +2,7 @@ import {useReducer} from 'react'
import {
ComAtprotoServerDescribeServer,
ComAtprotoServerCreateAccount,
BskyAgent,
} from '@atproto/api'
import {I18nContext, useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
@@ -13,6 +14,7 @@ import {cleanError} from '#/lib/strings/errors'
import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
import {ApiContext as SessionApiContext} from '#/state/session'
import {DEFAULT_SERVICE} from '#/lib/constants'
import parsePhoneNumber, {CountryCode} from 'libphonenumber-js'
export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
@@ -27,6 +29,10 @@ export type CreateAccountAction =
| {type: 'set-invite-code'; value: string}
| {type: 'set-email'; value: string}
| {type: 'set-password'; value: string}
| {type: 'set-phone-country'; value: CountryCode}
| {type: 'set-verification-phone'; value: string}
| {type: 'set-verification-code'; value: string}
| {type: 'set-has-requested-verification-code'; value: boolean}
| {type: 'set-handle'; value: string}
| {type: 'set-birth-date'; value: Date}
| {type: 'next'}
@@ -43,6 +49,10 @@ export interface CreateAccountState {
inviteCode: string
email: string
password: string
phoneCountry: CountryCode
verificationPhone: string
verificationCode: string
hasRequestedVerificationCode: boolean
handle: string
birthDate: Date
@@ -50,6 +60,7 @@ export interface CreateAccountState {
canBack: boolean
canNext: boolean
isInviteCodeRequired: boolean
isPhoneVerificationRequired: boolean
}
export type CreateAccountDispatch = (action: CreateAccountAction) => void
@@ -66,15 +77,55 @@ export function useCreateAccount() {
inviteCode: '',
email: '',
password: '',
phoneCountry: 'US',
verificationPhone: '',
verificationCode: '',
hasRequestedVerificationCode: false,
handle: '',
birthDate: DEFAULT_DATE,
canBack: false,
canNext: false,
isInviteCodeRequired: false,
isPhoneVerificationRequired: false,
})
}
export async function requestVerificationCode({
uiState,
uiDispatch,
_,
}: {
uiState: CreateAccountState
uiDispatch: CreateAccountDispatch
_: I18nContext['_']
}) {
const phoneNumber = parsePhoneNumber(
uiState.verificationPhone,
uiState.phoneCountry,
)?.number
if (!phoneNumber) {
return
}
uiDispatch({type: 'set-error', value: ''})
uiDispatch({type: 'set-processing', value: true})
uiDispatch({type: 'set-verification-phone', value: phoneNumber})
try {
const agent = new BskyAgent({service: uiState.serviceUrl})
await agent.com.atproto.temp.requestPhoneVerification({
phoneNumber,
})
uiDispatch({type: 'set-has-requested-verification-code', value: true})
} catch (e: any) {
logger.error(
`Failed to request sms verification code (${e.status} status)`,
{error: e},
)
uiDispatch({type: 'set-error', value: cleanError(e.toString())})
}
uiDispatch({type: 'set-processing', value: false})
}
export async function submit({
createAccount,
onboardingDispatch,
@@ -89,26 +140,36 @@ export async function submit({
_: I18nContext['_']
}) {
if (!uiState.email) {
uiDispatch({type: 'set-step', value: 2})
uiDispatch({type: 'set-step', value: 1})
return uiDispatch({
type: 'set-error',
value: _(msg`Please enter your email.`),
})
}
if (!EmailValidator.validate(uiState.email)) {
uiDispatch({type: 'set-step', value: 2})
uiDispatch({type: 'set-step', value: 1})
return uiDispatch({
type: 'set-error',
value: _(msg`Your email appears to be invalid.`),
})
}
if (!uiState.password) {
uiDispatch({type: 'set-step', value: 2})
uiDispatch({type: 'set-step', value: 1})
return uiDispatch({
type: 'set-error',
value: _(msg`Please choose your password.`),
})
}
if (
uiState.isPhoneVerificationRequired &&
(!uiState.verificationPhone || !uiState.verificationCode)
) {
uiDispatch({type: 'set-step', value: 2})
return uiDispatch({
type: 'set-error',
value: _(msg`Please enter the code you received by SMS.`),
})
}
if (!uiState.handle) {
uiDispatch({type: 'set-step', value: 3})
return uiDispatch({
@@ -127,6 +188,8 @@ export async function submit({
handle: createFullHandle(uiState.handle, uiState.userDomain),
password: uiState.password,
inviteCode: uiState.inviteCode.trim(),
verificationPhone: uiState.verificationPhone.trim(),
verificationCode: uiState.verificationCode.trim(),
})
} catch (e: any) {
onboardingDispatch({type: 'skip'}) // undo starting the onboard
@@ -135,6 +198,9 @@ export async function submit({
errMsg = _(
msg`Invite code not accepted. Check that you input it correctly and try again.`,
)
uiDispatch({type: 'set-step', value: 1})
} else if (e.error === 'InvalidPhoneVerification') {
uiDispatch({type: 'set-step', value: 2})
}
if ([400, 429].includes(e.status)) {
@@ -201,6 +267,22 @@ function createReducer({_}: {_: I18nContext['_']}) {
case 'set-password': {
return compute({...state, password: action.value})
}
case 'set-phone-country': {
return compute({...state, phoneCountry: action.value})
}
case 'set-verification-phone': {
return compute({
...state,
verificationPhone: action.value,
hasRequestedVerificationCode: false,
})
}
case 'set-verification-code': {
return compute({...state, verificationCode: action.value.trim()})
}
case 'set-has-requested-verification-code': {
return compute({...state, hasRequestedVerificationCode: action.value})
}
case 'set-handle': {
return compute({...state, handle: action.value})
}
@@ -208,7 +290,7 @@ function createReducer({_}: {_: I18nContext['_']}) {
return compute({...state, birthDate: action.value})
}
case 'next': {
if (state.step === 2) {
if (state.step === 1) {
if (!is13(state)) {
return compute({
...state,
@@ -218,10 +300,18 @@ function createReducer({_}: {_: I18nContext['_']}) {
})
}
}
return compute({...state, error: '', step: state.step + 1})
let increment = 1
if (state.step === 1 && !state.isPhoneVerificationRequired) {
increment = 2
}
return compute({...state, error: '', step: state.step + increment})
}
case 'back': {
return compute({...state, error: '', step: state.step - 1})
let decrement = 1
if (state.step === 3 && !state.isPhoneVerificationRequired) {
decrement = 2
}
return compute({...state, error: '', step: state.step - decrement})
}
}
}
@@ -230,12 +320,16 @@ function createReducer({_}: {_: I18nContext['_']}) {
function compute(state: CreateAccountState): CreateAccountState {
let canNext = true
if (state.step === 1) {
canNext = !!state.serviceDescription
} else if (state.step === 2) {
canNext =
!!state.serviceDescription &&
(!state.isInviteCodeRequired || !!state.inviteCode) &&
!!state.email &&
!!state.password
} else if (state.step === 2) {
canNext =
!state.isPhoneVerificationRequired ||
(!!state.verificationPhone &&
isValidVerificationCode(state.verificationCode))
} else if (state.step === 3) {
canNext = !!state.handle
}
@@ -244,5 +338,11 @@ function compute(state: CreateAccountState): CreateAccountState {
canBack: state.step > 1,
canNext,
isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
isPhoneVerificationRequired:
!!state.serviceDescription?.phoneVerificationRequired,
}
}
function isValidVerificationCode(str: string): boolean {
return /[0-9]{6}/.test(str)
}
+5 -3
View File
@@ -45,6 +45,7 @@ import {Gallery} from './photos/Gallery'
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {LabelsBtn} from './labels/LabelsBtn'
import {SelectLangBtn} from './select-language/SelectLangBtn'
import {SuggestedLanguage} from './select-language/SuggestedLanguage'
import {insertMentionAt} from 'lib/strings/mention-manip'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -73,7 +74,7 @@ export const ComposePost = observer(function ComposePost({
}: Props) {
const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
const {activeModals} = useModals()
const {isModalActive, activeModals} = useModals()
const {openModal, closeModal} = useModalControls()
const {closeComposer} = useComposerControls()
const {track} = useAnalytics()
@@ -175,11 +176,11 @@ export const ComposePost = observer(function ComposePost({
[onPressCancel],
)
useEffect(() => {
if (isWeb) {
if (isWeb && !isModalActive) {
window.addEventListener('keydown', onEscape)
return () => window.removeEventListener('keydown', onEscape)
}
}, [onEscape])
}, [onEscape, isModalActive])
const onPressAddLinkCard = useCallback(
(uri: string) => {
@@ -454,6 +455,7 @@ export const ComposePost = observer(function ComposePost({
))}
</View>
) : null}
<SuggestedLanguage text={richtext.text} />
<View style={[pal.border, styles.bottomBar]}>
{canSelectImages ? (
<>
@@ -0,0 +1,101 @@
import React, {useEffect, useState} from 'react'
import {StyleSheet, View} from 'react-native'
import lande from 'lande'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Text} from '../../util/text/Text'
import {Button} from '../../util/forms/Button'
import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers'
import {
toPostLanguages,
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {usePalette} from '#/lib/hooks/usePalette'
import {s} from '#/lib/styles'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
// fallbacks for safari
const onIdle = globalThis.requestIdleCallback || (cb => setTimeout(cb, 1))
const cancelIdle = globalThis.cancelIdleCallback || clearTimeout
export function SuggestedLanguage({text}: {text: string}) {
const [suggestedLanguage, setSuggestedLanguage] = useState<string>()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const pal = usePalette('default')
const {_} = useLingui()
useEffect(() => {
const textTrimmed = text.trim()
// Don't run the language model on small posts, the results are likely
// to be inaccurate anyway.
if (textTrimmed.length < 40) {
setSuggestedLanguage(undefined)
return
}
const idle = onIdle(() => {
// Only select languages that have a high confidence and convert to code2
const result = lande(textTrimmed).filter(
([lang, value]) => value >= 0.97 && code3ToCode2Strict(lang),
)
setSuggestedLanguage(
result.length > 0 ? code3ToCode2Strict(result[0][0]) : undefined,
)
})
return () => cancelIdle(idle)
}, [text])
return suggestedLanguage &&
!toPostLanguages(langPrefs.postLanguage).includes(suggestedLanguage) ? (
<View style={[pal.border, styles.infoBar]}>
<FontAwesomeIcon
icon="language"
style={pal.text as FontAwesomeIconStyle}
size={24}
/>
<Text style={[pal.text, s.flex1]}>
<Trans>
Are you writing in{' '}
<Text type="sm-bold" style={pal.text}>
{codeToLanguageName(suggestedLanguage)}
</Text>
?
</Trans>
</Text>
<Button
type="default"
onPress={() => setLangPrefs.setPostLanguage(suggestedLanguage)}
accessibilityLabel={_(
msg`Change post language to ${codeToLanguageName(suggestedLanguage)}`,
)}
accessibilityHint="">
<Text type="button" style={[pal.link, s.fw600]}>
<Trans>Yes</Trans>
</Text>
</Button>
</View>
) : null
}
const styles = StyleSheet.create({
infoBar: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 16,
paddingVertical: 12,
marginHorizontal: 10,
marginBottom: 10,
},
})
+10 -6
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {StyleSheet, View, Pressable} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import ImageView from './ImageViewing'
import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip'
@@ -107,12 +107,16 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
{altText ? (
<Pressable
onPress={() => setAltExpanded(!isAltExpanded)}
onLongPress={() => {}}
accessibilityRole="button">
<Text
style={[s.gray3, styles.footerText]}
numberOfLines={isAltExpanded ? undefined : 3}>
{altText}
</Text>
<View>
<Text
selectable
style={[s.gray3, styles.footerText]}
numberOfLines={isAltExpanded ? undefined : 3}>
{altText}
</Text>
</View>
</Pressable>
) : null}
<View style={styles.footerBtns}>
+13 -5
View File
@@ -94,15 +94,23 @@ export const ListCard = ({
</Trans>
))}
</Text>
{!!list.viewer?.muted && (
<View style={s.flexRow}>
<View style={s.flexRow}>
{list.viewer?.muted ? (
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
<Trans>Subscribed</Trans>
<Trans>Muted</Trans>
</Text>
</View>
</View>
)}
) : null}
{list.viewer?.blocked ? (
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
<Trans>Blocked</Trans>
</Text>
</View>
) : null}
</View>
</View>
{renderButton ? (
<View style={styles.layoutButton}>{renderButton()}</View>

Some files were not shown because too many files have changed in this diff Show More