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() await server?.close()
console.log('Starting new server') console.log('Starting new server')
const inviteRequired = url?.query && 'invite' in url.query 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) console.log('Listening at', server.pdsUrl)
if (url?.query) { if (url?.query) {
if ('users' in 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 element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen') 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 device.takeScreenshot('2- selected other server')
await element(by.id('customServerInput')).clearText() await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerInput')).typeText(service) await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap()
await device.takeScreenshot('3- input test server URL') 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('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2') await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details') await device.takeScreenshot('4- entered account details')
@@ -31,7 +29,7 @@ describe('Create account', () => {
await element(by.id('nextBtn')).tap() await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('e2e-test') 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() await element(by.id('nextBtn')).tap()
+4 -9
View File
@@ -1,10 +1,5 @@
/* eslint-env detox/detox */ /* 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 {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox' import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util' import {openApp, loginAsAlice, createServer} from '../util'
@@ -31,12 +26,12 @@ describe('invite-codes', () => {
await element(by.id('e2eOpenLoggedOutView')).tap() await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap() await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen') 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 device.takeScreenshot('2- selected other server')
await element(by.id('customServerInput')).clearText() await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerInput')).typeText(service) await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap()
await device.takeScreenshot('3- input test server URL') 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('inviteCodeInput')).typeText(inviteCode)
await element(by.id('emailInput')).typeText('example@test.com') await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2') 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) await sleep(3000)
} }
export async function createServer(path = '') { export async function createServer(path = ''): Promise<string> {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
var req = http.request( var req = http.request(
{ {
+7 -7
View File
@@ -463,44 +463,44 @@ describe('parseEmbedPlayerFromUrl', () => {
type: 'youtube_video', type: 'youtube_video',
source: 'youtube', source: 'youtube',
playerUri: 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', type: 'youtube_video',
source: 'youtube', source: 'youtube',
playerUri: 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', type: 'youtube_video',
source: 'youtube', source: 'youtube',
playerUri: 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', type: 'youtube_video',
source: 'youtube', source: 'youtube',
playerUri: 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', type: 'youtube_video',
source: 'youtube', source: 'youtube',
playerUri: 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', type: 'youtube_short',
source: 'youtubeShorts', source: 'youtubeShorts',
hideDetails: true, hideDetails: true,
playerUri: 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', type: 'youtube_video',
source: 'youtube', source: 'youtube',
playerUri: playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1&start=0',
}, },
undefined, undefined,
+1 -1
View File
@@ -25,7 +25,7 @@ module.exports = function () {
/** /**
* Android build number. Must be incremented for each release. * 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 * 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 { if err != nil {
log.Warn("failed to fetch author feed", "did", did, "err", err) log.Warn("failed to fetch author feed", "did", did, "err", err)
return err return err
+25 -6
View File
@@ -39,30 +39,49 @@
height: calc(100% + env(safe-area-inset-top)); 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 */ /* Color theming */
:root { :root {
--text: black; --text: black;
--background: white; --background: white;
--backgroundLight: #F3F3F8; --backgroundLight: hsl(211, 20%, 95%);
} }
html.colorMode--dark { html.colorMode--dark {
--text: white; --text: white;
--background: black; --background: hsl(211, 20%, 4%);
--backgroundLight: #26272D; --backgroundLight: hsl(211, 20%, 20%);
color-scheme: dark; color-scheme: dark;
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
html.colorMode--system { html.colorMode--system {
--text: black; --text: black;
--background: white; --background: white;
--backgroundLight: #F3F3F8; --backgroundLight: hsl(211, 20%, 95%);
} }
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html.colorMode--system { html.colorMode--system {
--text: white; --text: white;
--background: black; --background: hsl(211, 20%, 4%);
--backgroundLight: #26272D; --backgroundLight: hsl(211, 20%, 20%);
color-scheme: dark; color-scheme: dark;
} }
} }
+35 -3
View File
@@ -1,7 +1,7 @@
import net from 'net' import net from 'net'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import {TestNetwork} from '@atproto/dev-env' import {TestNetwork, TestPds} from '@atproto/dev-env'
import {AtUri, BskyAgent} from '@atproto/api' import {AtUri, BskyAgent} from '@atproto/api'
export interface TestUser { export interface TestUser {
@@ -55,19 +55,36 @@ class StringIdGenerator {
const ids = new StringIdGenerator() const ids = new StringIdGenerator()
export async function createServer( export async function createServer(
{inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false}, {
inviteRequired,
phoneRequired,
}: {inviteRequired: boolean; phoneRequired: boolean} = {
inviteRequired: false,
phoneRequired: false,
},
): Promise<TestPDS> { ): Promise<TestPDS> {
const port = await getPort() const port = 3000
const port2 = await getPort(port + 1) const port2 = await getPort(port + 1)
const port3 = await getPort(port2 + 1) const port3 = await getPort(port2 + 1)
const pdsUrl = `http://localhost:${port}` const pdsUrl = `http://localhost:${port}`
const id = ids.next() const id = ids.next()
const phoneParams = phoneRequired
? {
phoneVerificationRequired: true,
twilioAccountSid: 'ACXXXXXXX',
twilioAuthToken: 'AUTH',
twilioServiceSid: 'VAXXXXXXXX',
}
: {}
const testNet = await TestNetwork.create({ const testNet = await TestNetwork.create({
pds: { pds: {
port, port,
hostname: 'localhost', hostname: 'localhost',
dbPostgresSchema: `pds_${id}`,
inviteRequired, inviteRequired,
...phoneParams,
}, },
bsky: { bsky: {
dbPostgresSchema: `bsky_${id}`, dbPostgresSchema: `bsky_${id}`,
@@ -76,6 +93,7 @@ export async function createServer(
}, },
plc: {port: port2}, plc: {port: port2},
}) })
mockTwilio(testNet.pds)
const pic = fs.readFileSync( const pic = fs.readFileSync(
path.join(__dirname, '..', 'assets', 'default-avatar.png'), path.join(__dirname, '..', 'assets', 'default-avatar.png'),
@@ -144,6 +162,8 @@ class Mocker {
email, email,
handle: name + '.test', handle: name + '.test',
password: 'hunter2', password: 'hunter2',
verificationPhone: '1234567890',
verificationCode: '000000',
}) })
await agent.upsertProfile(async () => { await agent.upsertProfile(async () => {
const blob = await agent.uploadBlob(this.pic, { 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') 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} */ /** @type {import('@lingui/conf').LinguiConfig} */
module.exports = { 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: [ catalogs: [
{ {
path: '<rootDir>/src/locale/locales/{locale}/messages', path: '<rootDir>/src/locale/locales/{locale}/messages',
+24 -22
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.63.0", "version": "1.66.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -12,7 +12,7 @@
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"web": "expo start --web", "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", "build-all": "yarn intl:build && eas build --platform all",
"start": "expo start --dev-client", "start": "expo start --dev-client",
"start:prod": "expo start --dev-client --no-dev --minify", "start:prod": "expo start --dev-client --no-dev --minify",
@@ -39,7 +39,7 @@
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android" "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.8.0", "@atproto/api": "^0.9.1",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
@@ -60,7 +60,7 @@
"@react-native-community/datetimepicker": "7.6.1", "@react-native-community/datetimepicker": "7.6.1",
"@react-native-masked-view/masked-view": "0.3.0", "@react-native-masked-view/masked-view": "0.3.0",
"@react-native-menu/menu": "^0.8.0", "@react-native-menu/menu": "^0.8.0",
"@react-native-picker/picker": "2.5.1", "@react-native-picker/picker": "2.6.1",
"@react-navigation/bottom-tabs": "^6.5.7", "@react-navigation/bottom-tabs": "^6.5.7",
"@react-navigation/drawer": "^6.6.2", "@react-navigation/drawer": "^6.6.2",
"@react-navigation/native": "^6.1.6", "@react-navigation/native": "^6.1.6",
@@ -70,6 +70,7 @@
"@segment/analytics-react-native": "^2.10.1", "@segment/analytics-react-native": "^2.10.1",
"@segment/sovran-react-native": "^0.4.5", "@segment/sovran-react-native": "^0.4.5",
"@sentry/react-native": "5.5.0", "@sentry/react-native": "5.5.0",
"@tamagui/focus-scope": "^1.84.1",
"@tanstack/react-query": "^5.8.1", "@tanstack/react-query": "^5.8.1",
"@tiptap/core": "^2.0.0-beta.220", "@tiptap/core": "^2.0.0-beta.220",
"@tiptap/extension-document": "^2.0.0-beta.220", "@tiptap/extension-document": "^2.0.0-beta.220",
@@ -94,30 +95,32 @@
"email-validator": "^2.0.4", "email-validator": "^2.0.4",
"emoji-mart": "^5.5.2", "emoji-mart": "^5.5.2",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"expo": "^50.0.0-preview.7", "expo": "^50.0.0-preview.10",
"expo-application": "~5.8.1", "expo-application": "~5.8.2",
"expo-build-properties": "^0.11.0", "expo-build-properties": "^0.11.0",
"expo-camera": "~14.0.1", "expo-camera": "~14.0.1",
"expo-constants": "~15.4.2", "expo-constants": "~15.4.3",
"expo-dev-client": "~3.3.4", "expo-dev-client": "~3.3.5",
"expo-device": "~5.9.1", "expo-device": "~5.9.2",
"expo-image": "~1.10.1", "expo-image": "~1.10.3",
"expo-image-manipulator": "^11.8.0", "expo-image-manipulator": "^11.8.0",
"expo-image-picker": "~14.7.1", "expo-image-picker": "~14.7.1",
"expo-localization": "~14.8.1", "expo-localization": "~14.8.2",
"expo-media-library": "~15.9.1", "expo-media-library": "~15.9.1",
"expo-notifications": "~0.27.2", "expo-notifications": "~0.27.3",
"expo-sharing": "^11.10.0", "expo-sharing": "^11.10.0",
"expo-splash-screen": "~0.26.1", "expo-splash-screen": "~0.26.2",
"expo-status-bar": "~1.11.1", "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-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", "fast-text-encoding": "^1.0.6",
"history": "^5.3.0", "history": "^5.3.0",
"js-sha256": "^0.9.0", "js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lande": "^1.0.10", "lande": "^1.0.10",
"libphonenumber-js": "^1.10.53",
"lodash.chunk": "^4.2.0", "lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
@@ -140,7 +143,7 @@
"react-avatar-editor": "^13.0.0", "react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0", "react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.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-appstate-hook": "^1.0.6",
"react-native-drawer-layout": "^4.0.0-alpha.3", "react-native-drawer-layout": "^4.0.0-alpha.3",
"react-native-fs": "^2.20.0", "react-native-fs": "^2.20.0",
@@ -148,24 +151,23 @@
"react-native-get-random-values": "~1.8.0", "react-native-get-random-values": "~1.8.0",
"react-native-haptic-feedback": "^1.14.0", "react-native-haptic-feedback": "^1.14.0",
"react-native-image-crop-picker": "^0.38.1", "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-ios-context-menu": "^1.15.3",
"react-native-linear-gradient": "^2.6.2", "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-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress", "react-native-progress": "bluesky-social/react-native-progress",
"react-native-reanimated": "^3.6.0", "react-native-reanimated": "^3.6.0",
"react-native-root-siblings": "^4.1.1", "react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "4.7.4", "react-native-safe-area-context": "4.8.2",
"react-native-screens": "~3.27.0", "react-native-screens": "~3.29.0",
"react-native-svg": "14.0.0", "react-native-svg": "14.1.0",
"react-native-url-polyfill": "^1.3.0", "react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.1", "react-native-uuid": "^2.0.1",
"react-native-version-number": "^0.3.6", "react-native-version-number": "^0.3.6",
"react-native-web": "~0.19.6", "react-native-web": "~0.19.6",
"react-native-web-linear-gradient": "^1.1.2", "react-native-web-linear-gradient": "^1.1.2",
"react-native-web-webview": "^1.0.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", "react-responsive": "^9.0.2",
"rn-fetch-blob": "^0.12.0", "rn-fetch-blob": "^0.12.0",
"sentry-expo": "~7.0.1", "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 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 --- a/node_modules/metro-transform-worker/src/index.js
+++ b/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 }) { @@ -189,6 +189,10 @@ async function transformJS(file, { config, options, projectRoot }) {
+35 -24
View File
@@ -13,6 +13,8 @@ import {
import 'view/icons' import 'view/icons'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {init as initPersistedState} from '#/state/persisted' import {init as initPersistedState} from '#/state/persisted'
import {listenSessionDropped} from './state/events' import {listenSessionDropped} from './state/events'
import {useColorMode} from 'state/shell' import {useColorMode} from 'state/shell'
@@ -25,6 +27,7 @@ import {queryClient} from 'lib/react-query'
import {TestCtrls} from 'view/com/testing/TestCtrls' import {TestCtrls} from 'view/com/testing/TestCtrls'
import {Provider as ShellStateProvider} from 'state/shell' import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals' import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as DialogStateProvider} from 'state/dialogs'
import {Provider as LightboxStateProvider} from 'state/lightbox' import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads' import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites' import {Provider as InvitesStateProvider} from 'state/invites'
@@ -39,6 +42,7 @@ import {
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {Splash} from '#/Splash' import {Splash} from '#/Splash'
import {Provider as PortalProvider} from '#/components/Portal'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -48,6 +52,7 @@ function InnerApp() {
const colorMode = useColorMode() const colorMode = useColorMode()
const {isInitialLoad, currentAccount} = useSession() const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi() const {resumeSession} = useSessionApi()
const theme = useColorModeTheme(colorMode)
const {_} = useLingui() const {_} = useLingui()
// init // init
@@ -63,25 +68,27 @@ function InnerApp() {
return ( return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}> <SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Splash isReady={!isInitialLoad}> <Alf theme={theme}>
<React.Fragment <Splash isReady={!isInitialLoad}>
// Resets the entire tree below when it changes: <React.Fragment
key={currentAccount?.did}> // Resets the entire tree below when it changes:
<LoggedOutViewProvider> key={currentAccount?.did}>
<UnreadNotifsProvider> <LoggedOutViewProvider>
<ThemeProvider theme={colorMode}> <UnreadNotifsProvider>
{/* All components should be within this provider */} <ThemeProvider theme={colorMode}>
<RootSiblingParent> {/* All components should be within this provider */}
<GestureHandlerRootView style={s.h100pct}> <RootSiblingParent>
<TestCtrls /> <GestureHandlerRootView style={s.h100pct}>
<Shell /> <TestCtrls />
</GestureHandlerRootView> <Shell />
</RootSiblingParent> </GestureHandlerRootView>
</ThemeProvider> </RootSiblingParent>
</UnreadNotifsProvider> </ThemeProvider>
</LoggedOutViewProvider> </UnreadNotifsProvider>
</React.Fragment> </LoggedOutViewProvider>
</Splash> </React.Fragment>
</Splash>
</Alf>
</SafeAreaProvider> </SafeAreaProvider>
) )
} }
@@ -109,11 +116,15 @@ function App() {
<MutedThreadsProvider> <MutedThreadsProvider>
<InvitesStateProvider> <InvitesStateProvider>
<ModalStateProvider> <ModalStateProvider>
<LightboxStateProvider> <DialogStateProvider>
<I18nProvider> <LightboxStateProvider>
<InnerApp /> <I18nProvider>
</I18nProvider> <PortalProvider>
</LightboxStateProvider> <InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider> </ModalStateProvider>
</InvitesStateProvider> </InvitesStateProvider>
</MutedThreadsProvider> </MutedThreadsProvider>
+12 -6
View File
@@ -8,6 +8,7 @@ import {RootSiblingParent} from 'react-native-root-siblings'
import 'view/icons' import 'view/icons'
import {ThemeProvider as Alf} from '#/alf' import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {init as initPersistedState} from '#/state/persisted' import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell' import {useColorMode} from 'state/shell'
import {Shell} from 'view/shell/index' import {Shell} from 'view/shell/index'
@@ -16,6 +17,7 @@ import {ThemeProvider} from 'lib/ThemeContext'
import {queryClient} from 'lib/react-query' import {queryClient} from 'lib/react-query'
import {Provider as ShellStateProvider} from 'state/shell' import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals' import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as DialogStateProvider} from 'state/dialogs'
import {Provider as LightboxStateProvider} from 'state/lightbox' import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads' import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites' import {Provider as InvitesStateProvider} from 'state/invites'
@@ -29,7 +31,7 @@ import {
} from 'state/session' } from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {Provider as PortalProvider} from '#/components/Portal'
function InnerApp() { function InnerApp() {
const {isInitialLoad, currentAccount} = useSession() const {isInitialLoad, currentAccount} = useSession()
@@ -92,11 +94,15 @@ function App() {
<MutedThreadsProvider> <MutedThreadsProvider>
<InvitesStateProvider> <InvitesStateProvider>
<ModalStateProvider> <ModalStateProvider>
<LightboxStateProvider> <DialogStateProvider>
<I18nProvider> <LightboxStateProvider>
<InnerApp /> <I18nProvider>
</I18nProvider> <PortalProvider>
</LightboxStateProvider> <InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider> </ModalStateProvider>
</InvitesStateProvider> </InvitesStateProvider>
</MutedThreadsProvider> </MutedThreadsProvider>
+15 -26
View File
@@ -1,5 +1,4 @@
import * as React from 'react' import * as React from 'react'
import {StyleSheet} from 'react-native'
import { import {
NavigationContainer, NavigationContainer,
createNavigationContainerRef, createNavigationContainerRef,
@@ -25,7 +24,6 @@ import {
import {BottomBar} from './view/shell/bottom-bar/BottomBar' import {BottomBar} from './view/shell/bottom-bar/BottomBar'
import {buildStateObject} from 'lib/routes/helpers' import {buildStateObject} from 'lib/routes/helpers'
import {State, RouteParams} from 'lib/routes/types' import {State, RouteParams} from 'lib/routes/types'
import {colors} from 'lib/styles'
import {isAndroid, isNative} from 'platform/detection' import {isAndroid, isNative} from 'platform/detection'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {router} from './routes' import {router} from './routes'
@@ -61,7 +59,7 @@ import {ProfileListScreen} from './view/screens/ProfileList'
import {PostThreadScreen} from './view/screens/PostThread' import {PostThreadScreen} from './view/screens/PostThread'
import {PostLikedByScreen} from './view/screens/PostLikedBy' import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy' 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 {LogScreen} from './view/screens/Log'
import {SupportScreen} from './view/screens/Support' import {SupportScreen} from './view/screens/Support'
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy' import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
@@ -144,7 +142,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
name="Profile" name="Profile"
getComponent={() => ProfileScreen} getComponent={() => ProfileScreen}
options={({route}) => ({ options={({route}) => ({
title: title(msg`@${route.params.name}`), title: bskyTitle(`@${route.params.name}`, unreadCountLabel),
animation: 'none', animation: 'none',
})} })}
/> />
@@ -200,8 +198,8 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
/> />
<Stack.Screen <Stack.Screen
name="Debug" name="Debug"
getComponent={() => DebugScreen} getComponent={() => Storybook}
options={{title: title(msg`Debug`), requireAuth: true}} options={{title: title(msg`Storybook`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="Log" name="Log"
@@ -299,7 +297,7 @@ function TabsNavigator() {
} }
function HomeTabNavigator() { function HomeTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) const pal = usePalette('default')
return ( return (
<HomeTab.Navigator <HomeTab.Navigator
@@ -309,7 +307,7 @@ function HomeTabNavigator() {
fullScreenGestureEnabled: true, fullScreenGestureEnabled: true,
headerShown: false, headerShown: false,
animationDuration: 250, animationDuration: 250,
contentStyle, contentStyle: pal.view,
}}> }}>
<HomeTab.Screen <HomeTab.Screen
name="Home" name="Home"
@@ -322,7 +320,7 @@ function HomeTabNavigator() {
} }
function SearchTabNavigator() { function SearchTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) const pal = usePalette('default')
return ( return (
<SearchTab.Navigator <SearchTab.Navigator
screenOptions={{ screenOptions={{
@@ -331,7 +329,7 @@ function SearchTabNavigator() {
fullScreenGestureEnabled: true, fullScreenGestureEnabled: true,
headerShown: false, headerShown: false,
animationDuration: 250, animationDuration: 250,
contentStyle, contentStyle: pal.view,
}}> }}>
<SearchTab.Screen name="Search" getComponent={() => SearchScreen} /> <SearchTab.Screen name="Search" getComponent={() => SearchScreen} />
{commonScreens(SearchTab as typeof HomeTab)} {commonScreens(SearchTab as typeof HomeTab)}
@@ -340,7 +338,7 @@ function SearchTabNavigator() {
} }
function FeedsTabNavigator() { function FeedsTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) const pal = usePalette('default')
return ( return (
<FeedsTab.Navigator <FeedsTab.Navigator
screenOptions={{ screenOptions={{
@@ -349,7 +347,7 @@ function FeedsTabNavigator() {
fullScreenGestureEnabled: true, fullScreenGestureEnabled: true,
headerShown: false, headerShown: false,
animationDuration: 250, animationDuration: 250,
contentStyle, contentStyle: pal.view,
}}> }}>
<FeedsTab.Screen <FeedsTab.Screen
name="Feeds" name="Feeds"
@@ -362,7 +360,7 @@ function FeedsTabNavigator() {
} }
function NotificationsTabNavigator() { function NotificationsTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) const pal = usePalette('default')
return ( return (
<NotificationsTab.Navigator <NotificationsTab.Navigator
screenOptions={{ screenOptions={{
@@ -371,7 +369,7 @@ function NotificationsTabNavigator() {
fullScreenGestureEnabled: true, fullScreenGestureEnabled: true,
headerShown: false, headerShown: false,
animationDuration: 250, animationDuration: 250,
contentStyle, contentStyle: pal.view,
}}> }}>
<NotificationsTab.Screen <NotificationsTab.Screen
name="Notifications" name="Notifications"
@@ -384,7 +382,7 @@ function NotificationsTabNavigator() {
} }
function MyProfileTabNavigator() { function MyProfileTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) const pal = usePalette('default')
return ( return (
<MyProfileTab.Navigator <MyProfileTab.Navigator
screenOptions={{ screenOptions={{
@@ -393,7 +391,7 @@ function MyProfileTabNavigator() {
fullScreenGestureEnabled: true, fullScreenGestureEnabled: true,
headerShown: false, headerShown: false,
animationDuration: 250, animationDuration: 250,
contentStyle, contentStyle: pal.view,
}}> }}>
<MyProfileTab.Screen <MyProfileTab.Screen
// @ts-ignore // TODO: fix this broken type in ProfileScreen // @ts-ignore // TODO: fix this broken type in ProfileScreen
@@ -424,7 +422,7 @@ const FlatNavigator = () => {
fullScreenGestureEnabled: true, fullScreenGestureEnabled: true,
headerShown: false, headerShown: false,
animationDuration: 250, animationDuration: 250,
contentStyle: [pal.view], contentStyle: pal.view,
}}> }}>
<Flat.Screen <Flat.Screen
name="Home" 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 let didInit = false
function logModuleInitTime() { function logModuleInitTime() {
if (didInit) { if (didInit) {
+266 -78
View File
@@ -4,6 +4,9 @@ export const atoms = {
/* /*
* Positioning * Positioning
*/ */
fixed: {
position: 'fixed',
},
absolute: { absolute: {
position: 'absolute', position: 'absolute',
}, },
@@ -32,6 +35,10 @@ export const atoms = {
zIndex: 50, zIndex: 50,
}, },
overflow_hidden: {
overflow: 'hidden',
},
/* /*
* Width * Width
*/ */
@@ -45,6 +52,12 @@ export const atoms = {
/* /*
* Border radius * Border radius
*/ */
rounded_2xs: {
borderRadius: tokens.borderRadius._2xs,
},
rounded_xs: {
borderRadius: tokens.borderRadius.xs,
},
rounded_sm: { rounded_sm: {
borderRadius: tokens.borderRadius.sm, borderRadius: tokens.borderRadius.sm,
}, },
@@ -58,8 +71,8 @@ export const atoms = {
/* /*
* Flex * Flex
*/ */
gap_xxs: { gap_2xs: {
gap: tokens.space.xxs, gap: tokens.space._2xs,
}, },
gap_xs: { gap_xs: {
gap: tokens.space.xs, gap: tokens.space.xs,
@@ -76,8 +89,17 @@ export const atoms = {
gap_xl: { gap_xl: {
gap: tokens.space.xl, gap: tokens.space.xl,
}, },
gap_xxl: { gap_2xl: {
gap: tokens.space.xxl, gap: tokens.space._2xl,
},
gap_3xl: {
gap: tokens.space._3xl,
},
gap_4xl: {
gap: tokens.space._4xl,
},
gap_5xl: {
gap: tokens.space._5xl,
}, },
flex: { flex: {
display: 'flex', display: 'flex',
@@ -125,9 +147,9 @@ export const atoms = {
text_right: { text_right: {
textAlign: 'right', textAlign: 'right',
}, },
text_xxs: { text_2xs: {
fontSize: tokens.fontSize.xxs, fontSize: tokens.fontSize._2xs,
lineHeight: tokens.fontSize.xxs, lineHeight: tokens.fontSize._2xs,
}, },
text_xs: { text_xs: {
fontSize: tokens.fontSize.xs, fontSize: tokens.fontSize.xs,
@@ -149,9 +171,21 @@ export const atoms = {
fontSize: tokens.fontSize.xl, fontSize: tokens.fontSize.xl,
lineHeight: tokens.fontSize.xl, lineHeight: tokens.fontSize.xl,
}, },
text_xxl: { text_2xl: {
fontSize: tokens.fontSize.xxl, fontSize: tokens.fontSize._2xl,
lineHeight: tokens.fontSize.xxl, 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: { leading_tight: {
lineHeight: 1.25, lineHeight: 1.25,
@@ -162,11 +196,8 @@ export const atoms = {
font_normal: { font_normal: {
fontWeight: tokens.fontWeight.normal, fontWeight: tokens.fontWeight.normal,
}, },
font_semibold: {
fontWeight: tokens.fontWeight.semibold,
},
font_bold: { font_bold: {
fontWeight: tokens.fontWeight.bold, fontWeight: tokens.fontWeight.semibold,
}, },
/* /*
@@ -182,11 +213,30 @@ export const atoms = {
borderBottomWidth: 1, 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 * Padding
*/ */
p_xxs: { p_2xs: {
padding: tokens.space.xxs, padding: tokens.space._2xs,
}, },
p_xs: { p_xs: {
padding: tokens.space.xs, padding: tokens.space.xs,
@@ -203,12 +253,21 @@ export const atoms = {
p_xl: { p_xl: {
padding: tokens.space.xl, padding: tokens.space.xl,
}, },
p_xxl: { p_2xl: {
padding: tokens.space.xxl, padding: tokens.space._2xl,
}, },
px_xxs: { p_3xl: {
paddingLeft: tokens.space.xxs, padding: tokens.space._3xl,
paddingRight: tokens.space.xxs, },
p_4xl: {
padding: tokens.space._4xl,
},
p_5xl: {
padding: tokens.space._5xl,
},
px_2xs: {
paddingLeft: tokens.space._2xs,
paddingRight: tokens.space._2xs,
}, },
px_xs: { px_xs: {
paddingLeft: tokens.space.xs, paddingLeft: tokens.space.xs,
@@ -230,13 +289,25 @@ export const atoms = {
paddingLeft: tokens.space.xl, paddingLeft: tokens.space.xl,
paddingRight: tokens.space.xl, paddingRight: tokens.space.xl,
}, },
px_xxl: { px_2xl: {
paddingLeft: tokens.space.xxl, paddingLeft: tokens.space._2xl,
paddingRight: tokens.space.xxl, paddingRight: tokens.space._2xl,
}, },
py_xxs: { px_3xl: {
paddingTop: tokens.space.xxs, paddingLeft: tokens.space._3xl,
paddingBottom: tokens.space.xxs, 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: { py_xs: {
paddingTop: tokens.space.xs, paddingTop: tokens.space.xs,
@@ -258,12 +329,24 @@ export const atoms = {
paddingTop: tokens.space.xl, paddingTop: tokens.space.xl,
paddingBottom: tokens.space.xl, paddingBottom: tokens.space.xl,
}, },
py_xxl: { py_2xl: {
paddingTop: tokens.space.xxl, paddingTop: tokens.space._2xl,
paddingBottom: tokens.space.xxl, paddingBottom: tokens.space._2xl,
}, },
pt_xxs: { py_3xl: {
paddingTop: tokens.space.xxs, 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: { pt_xs: {
paddingTop: tokens.space.xs, paddingTop: tokens.space.xs,
@@ -280,11 +363,20 @@ export const atoms = {
pt_xl: { pt_xl: {
paddingTop: tokens.space.xl, paddingTop: tokens.space.xl,
}, },
pt_xxl: { pt_2xl: {
paddingTop: tokens.space.xxl, paddingTop: tokens.space._2xl,
}, },
pb_xxs: { pt_3xl: {
paddingBottom: tokens.space.xxs, paddingTop: tokens.space._3xl,
},
pt_4xl: {
paddingTop: tokens.space._4xl,
},
pt_5xl: {
paddingTop: tokens.space._5xl,
},
pb_2xs: {
paddingBottom: tokens.space._2xs,
}, },
pb_xs: { pb_xs: {
paddingBottom: tokens.space.xs, paddingBottom: tokens.space.xs,
@@ -301,11 +393,20 @@ export const atoms = {
pb_xl: { pb_xl: {
paddingBottom: tokens.space.xl, paddingBottom: tokens.space.xl,
}, },
pb_xxl: { pb_2xl: {
paddingBottom: tokens.space.xxl, paddingBottom: tokens.space._2xl,
}, },
pl_xxs: { pb_3xl: {
paddingLeft: tokens.space.xxs, paddingBottom: tokens.space._3xl,
},
pb_4xl: {
paddingBottom: tokens.space._4xl,
},
pb_5xl: {
paddingBottom: tokens.space._5xl,
},
pl_2xs: {
paddingLeft: tokens.space._2xs,
}, },
pl_xs: { pl_xs: {
paddingLeft: tokens.space.xs, paddingLeft: tokens.space.xs,
@@ -322,11 +423,20 @@ export const atoms = {
pl_xl: { pl_xl: {
paddingLeft: tokens.space.xl, paddingLeft: tokens.space.xl,
}, },
pl_xxl: { pl_2xl: {
paddingLeft: tokens.space.xxl, paddingLeft: tokens.space._2xl,
}, },
pr_xxs: { pl_3xl: {
paddingRight: tokens.space.xxs, paddingLeft: tokens.space._3xl,
},
pl_4xl: {
paddingLeft: tokens.space._4xl,
},
pl_5xl: {
paddingLeft: tokens.space._5xl,
},
pr_2xs: {
paddingRight: tokens.space._2xs,
}, },
pr_xs: { pr_xs: {
paddingRight: tokens.space.xs, paddingRight: tokens.space.xs,
@@ -343,15 +453,24 @@ export const atoms = {
pr_xl: { pr_xl: {
paddingRight: tokens.space.xl, paddingRight: tokens.space.xl,
}, },
pr_xxl: { pr_2xl: {
paddingRight: tokens.space.xxl, paddingRight: tokens.space._2xl,
},
pr_3xl: {
paddingRight: tokens.space._3xl,
},
pr_4xl: {
paddingRight: tokens.space._4xl,
},
pr_5xl: {
paddingRight: tokens.space._5xl,
}, },
/* /*
* Margin * Margin
*/ */
m_xxs: { m_2xs: {
margin: tokens.space.xxs, margin: tokens.space._2xs,
}, },
m_xs: { m_xs: {
margin: tokens.space.xs, margin: tokens.space.xs,
@@ -368,12 +487,21 @@ export const atoms = {
m_xl: { m_xl: {
margin: tokens.space.xl, margin: tokens.space.xl,
}, },
m_xxl: { m_2xl: {
margin: tokens.space.xxl, margin: tokens.space._2xl,
}, },
mx_xxs: { m_3xl: {
marginLeft: tokens.space.xxs, margin: tokens.space._3xl,
marginRight: tokens.space.xxs, },
m_4xl: {
margin: tokens.space._4xl,
},
m_5xl: {
margin: tokens.space._5xl,
},
mx_2xs: {
marginLeft: tokens.space._2xs,
marginRight: tokens.space._2xs,
}, },
mx_xs: { mx_xs: {
marginLeft: tokens.space.xs, marginLeft: tokens.space.xs,
@@ -395,13 +523,25 @@ export const atoms = {
marginLeft: tokens.space.xl, marginLeft: tokens.space.xl,
marginRight: tokens.space.xl, marginRight: tokens.space.xl,
}, },
mx_xxl: { mx_2xl: {
marginLeft: tokens.space.xxl, marginLeft: tokens.space._2xl,
marginRight: tokens.space.xxl, marginRight: tokens.space._2xl,
}, },
my_xxs: { mx_3xl: {
marginTop: tokens.space.xxs, marginLeft: tokens.space._3xl,
marginBottom: tokens.space.xxs, 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: { my_xs: {
marginTop: tokens.space.xs, marginTop: tokens.space.xs,
@@ -423,12 +563,24 @@ export const atoms = {
marginTop: tokens.space.xl, marginTop: tokens.space.xl,
marginBottom: tokens.space.xl, marginBottom: tokens.space.xl,
}, },
my_xxl: { my_2xl: {
marginTop: tokens.space.xxl, marginTop: tokens.space._2xl,
marginBottom: tokens.space.xxl, marginBottom: tokens.space._2xl,
}, },
mt_xxs: { my_3xl: {
marginTop: tokens.space.xxs, 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: { mt_xs: {
marginTop: tokens.space.xs, marginTop: tokens.space.xs,
@@ -445,11 +597,20 @@ export const atoms = {
mt_xl: { mt_xl: {
marginTop: tokens.space.xl, marginTop: tokens.space.xl,
}, },
mt_xxl: { mt_2xl: {
marginTop: tokens.space.xxl, marginTop: tokens.space._2xl,
}, },
mb_xxs: { mt_3xl: {
marginBottom: tokens.space.xxs, marginTop: tokens.space._3xl,
},
mt_4xl: {
marginTop: tokens.space._4xl,
},
mt_5xl: {
marginTop: tokens.space._5xl,
},
mb_2xs: {
marginBottom: tokens.space._2xs,
}, },
mb_xs: { mb_xs: {
marginBottom: tokens.space.xs, marginBottom: tokens.space.xs,
@@ -466,11 +627,20 @@ export const atoms = {
mb_xl: { mb_xl: {
marginBottom: tokens.space.xl, marginBottom: tokens.space.xl,
}, },
mb_xxl: { mb_2xl: {
marginBottom: tokens.space.xxl, marginBottom: tokens.space._2xl,
}, },
ml_xxs: { mb_3xl: {
marginLeft: tokens.space.xxs, marginBottom: tokens.space._3xl,
},
mb_4xl: {
marginBottom: tokens.space._4xl,
},
mb_5xl: {
marginBottom: tokens.space._5xl,
},
ml_2xs: {
marginLeft: tokens.space._2xs,
}, },
ml_xs: { ml_xs: {
marginLeft: tokens.space.xs, marginLeft: tokens.space.xs,
@@ -487,11 +657,20 @@ export const atoms = {
ml_xl: { ml_xl: {
marginLeft: tokens.space.xl, marginLeft: tokens.space.xl,
}, },
ml_xxl: { ml_2xl: {
marginLeft: tokens.space.xxl, marginLeft: tokens.space._2xl,
}, },
mr_xxs: { ml_3xl: {
marginRight: tokens.space.xxs, marginLeft: tokens.space._3xl,
},
ml_4xl: {
marginLeft: tokens.space._4xl,
},
ml_5xl: {
marginLeft: tokens.space._5xl,
},
mr_2xs: {
marginRight: tokens.space._2xs,
}, },
mr_xs: { mr_xs: {
marginRight: tokens.space.xs, marginRight: tokens.space.xs,
@@ -508,7 +687,16 @@ export const atoms = {
mr_xl: { mr_xl: {
marginRight: tokens.space.xl, marginRight: tokens.space.xl,
}, },
mr_xxl: { mr_2xl: {
marginRight: tokens.space.xxl, marginRight: tokens.space._2xl,
},
mr_3xl: {
marginRight: tokens.space._3xl,
},
mr_4xl: {
marginRight: tokens.space._4xl,
},
mr_5xl: {
marginRight: tokens.space._5xl,
}, },
} as const } as const
+1
View File
@@ -5,6 +5,7 @@ import * as themes from '#/alf/themes'
export * as tokens from '#/alf/tokens' export * as tokens from '#/alf/tokens'
export {atoms} from '#/alf/atoms' export {atoms} from '#/alf/atoms'
export * from '#/alf/util/platform' export * from '#/alf/util/platform'
export * from '#/alf/util/flatten'
type BreakpointName = keyof typeof breakpoints type BreakpointName = keyof typeof breakpoints
+259 -47
View File
@@ -1,108 +1,320 @@
import * as tokens from '#/alf/tokens' import * as tokens from '#/alf/tokens'
import type {Mutable} from '#/alf/types' 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 ReadonlyTheme = typeof light
export type Theme = Mutable<ReadonlyTheme> export type Theme = Mutable<ReadonlyTheme>
export type ReadonlyPalette = typeof lightPalette
export type Palette = Mutable<ReadonlyPalette>
export type Palette = { export const lightPalette = {
primary: string white: tokens.color.gray_0,
positive: string black: tokens.color.gray_1000,
negative: string
}
export const lightPalette: Palette = { contrast_25: tokens.color.gray_25,
primary: tokens.color.blue_500, contrast_50: tokens.color.gray_50,
positive: tokens.color.green_500, contrast_100: tokens.color.gray_100,
negative: tokens.color.red_500, 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 } as const
export const darkPalette: Palette = { export const darkPalette: Palette = {
primary: tokens.color.blue_500, white: tokens.color.gray_0,
positive: tokens.color.green_400, black: tokens.color.gray_1000,
negative: tokens.color.red_400,
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 } as const
export const light = { export const light = {
name: 'light',
palette: lightPalette, palette: lightPalette,
atoms: { atoms: {
text: { text: {
color: tokens.color.gray_1000, color: lightPalette.black,
}, },
text_contrast_700: { text_contrast_700: {
color: tokens.color.gray_700, color: lightPalette.contrast_700,
},
text_contrast_600: {
color: lightPalette.contrast_600,
}, },
text_contrast_500: { text_contrast_500: {
color: tokens.color.gray_500, color: lightPalette.contrast_500,
},
text_contrast_400: {
color: lightPalette.contrast_400,
}, },
text_inverted: { text_inverted: {
color: tokens.color.white, color: lightPalette.white,
}, },
bg: { 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: { bg_contrast_100: {
backgroundColor: tokens.color.gray_100, backgroundColor: lightPalette.contrast_100,
}, },
bg_contrast_200: { bg_contrast_200: {
backgroundColor: tokens.color.gray_200, backgroundColor: lightPalette.contrast_200,
}, },
bg_contrast_300: { bg_contrast_300: {
backgroundColor: tokens.color.gray_300, backgroundColor: lightPalette.contrast_300,
},
bg_positive: {
backgroundColor: tokens.color.green_500,
},
bg_negative: {
backgroundColor: tokens.color.red_400,
}, },
border: { border: {
borderColor: tokens.color.gray_200, borderColor: lightPalette.contrast_100,
}, },
border_contrast_500: { border_contrast: {
borderColor: tokens.color.gray_500, 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 = { export const dark: Theme = {
name: 'dark',
palette: darkPalette, palette: darkPalette,
atoms: { atoms: {
text: { text: {
color: tokens.color.white, color: darkPalette.white,
}, },
text_contrast_700: { text_contrast_700: {
color: tokens.color.gray_300, color: darkPalette.contrast_700,
},
text_contrast_600: {
color: darkPalette.contrast_600,
}, },
text_contrast_500: { text_contrast_500: {
color: tokens.color.gray_500, color: darkPalette.contrast_500,
},
text_contrast_400: {
color: darkPalette.contrast_400,
}, },
text_inverted: { text_inverted: {
color: tokens.color.gray_1000, color: darkPalette.black,
}, },
bg: { 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: { bg_contrast_100: {
backgroundColor: tokens.color.gray_900, backgroundColor: darkPalette.contrast_200,
}, },
bg_contrast_200: { bg_contrast_200: {
backgroundColor: tokens.color.gray_800, backgroundColor: darkPalette.contrast_300,
}, },
bg_contrast_300: { bg_contrast_300: {
backgroundColor: tokens.color.gray_700, backgroundColor: darkPalette.contrast_400,
},
bg_positive: {
backgroundColor: tokens.color.green_400,
},
bg_negative: {
backgroundColor: tokens.color.red_400,
}, },
border: { border: {
borderColor: tokens.color.gray_800, borderColor: darkPalette.contrast_100,
}, },
border_contrast_500: { border_contrast: {
borderColor: tokens.color.gray_500, 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 BLUE_HUE = 211
const GRAYSCALE_SATURATION = 22 const RED_HUE = 346
const GREEN_HUE = 152
export const color = { export const color = {
white: '#FFFFFF', trueBlack: '#000000',
gray_0: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 100%)`, gray_0: `hsl(${BLUE_HUE}, 20%, 100%)`,
gray_100: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 95%)`, gray_25: `hsl(${BLUE_HUE}, 20%, 97%)`,
gray_200: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 85%)`, gray_50: `hsl(${BLUE_HUE}, 20%, 95%)`,
gray_300: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 75%)`, gray_100: `hsl(${BLUE_HUE}, 20%, 90%)`,
gray_400: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 65%)`, gray_200: `hsl(${BLUE_HUE}, 20%, 80%)`,
gray_500: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 55%)`, gray_300: `hsl(${BLUE_HUE}, 20%, 70%)`,
gray_600: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 45%)`, gray_400: `hsl(${BLUE_HUE}, 20%, 60%)`,
gray_700: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 35%)`, gray_500: `hsl(${BLUE_HUE}, 20%, 50%)`,
gray_800: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 25%)`, gray_600: `hsl(${BLUE_HUE}, 20%, 42%)`,
gray_900: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 15%)`, gray_700: `hsl(${BLUE_HUE}, 20%, 34%)`,
gray_1000: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 5%)`, 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_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
blue_100: `hsl(${BLUE_HUE}, 99%, 93%)`, blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
blue_200: `hsl(${BLUE_HUE}, 99%, 83%)`, blue_100: `hsl(${BLUE_HUE}, 99%, 90%)`,
blue_300: `hsl(${BLUE_HUE}, 99%, 73%)`, blue_200: `hsl(${BLUE_HUE}, 99%, 80%)`,
blue_400: `hsl(${BLUE_HUE}, 99%, 63%)`, blue_300: `hsl(${BLUE_HUE}, 99%, 70%)`,
blue_400: `hsl(${BLUE_HUE}, 99%, 60%)`,
blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`, blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
blue_600: `hsl(${BLUE_HUE}, 99%, 43%)`, blue_600: `hsl(${BLUE_HUE}, 99%, 42%)`,
blue_700: `hsl(${BLUE_HUE}, 99%, 33%)`, blue_700: `hsl(${BLUE_HUE}, 99%, 34%)`,
blue_800: `hsl(${BLUE_HUE}, 99%, 23%)`, blue_800: `hsl(${BLUE_HUE}, 99%, 26%)`,
blue_900: `hsl(${BLUE_HUE}, 99%, 13%)`, blue_900: `hsl(${BLUE_HUE}, 99%, 18%)`,
blue_1000: `hsl(${BLUE_HUE}, 99%, 8%)`, blue_950: `hsl(${BLUE_HUE}, 99%, 10%)`,
blue_975: `hsl(${BLUE_HUE}, 99%, 7%)`,
green_0: `hsl(130, 60%, 100%)`, green_25: `hsl(${GREEN_HUE}, 82%, 97%)`,
green_100: `hsl(130, 60%, 95%)`, green_50: `hsl(${GREEN_HUE}, 82%, 95%)`,
green_200: `hsl(130, 60%, 85%)`, green_100: `hsl(${GREEN_HUE}, 82%, 90%)`,
green_300: `hsl(130, 60%, 75%)`, green_200: `hsl(${GREEN_HUE}, 82%, 80%)`,
green_400: `hsl(130, 60%, 65%)`, green_300: `hsl(${GREEN_HUE}, 82%, 70%)`,
green_500: `hsl(130, 60%, 55%)`, green_400: `hsl(${GREEN_HUE}, 82%, 60%)`,
green_600: `hsl(130, 60%, 45%)`, green_500: `hsl(${GREEN_HUE}, 82%, 50%)`,
green_700: `hsl(130, 60%, 35%)`, green_600: `hsl(${GREEN_HUE}, 82%, 42%)`,
green_800: `hsl(130, 60%, 25%)`, green_700: `hsl(${GREEN_HUE}, 82%, 34%)`,
green_900: `hsl(130, 60%, 15%)`, green_800: `hsl(${GREEN_HUE}, 82%, 26%)`,
green_1000: `hsl(130, 60%, 5%)`, 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_25: `hsl(${RED_HUE}, 91%, 97%)`,
red_100: `hsl(349, 96%, 95%)`, red_50: `hsl(${RED_HUE}, 91%, 95%)`,
red_200: `hsl(349, 96%, 85%)`, red_100: `hsl(${RED_HUE}, 91%, 90%)`,
red_300: `hsl(349, 96%, 75%)`, red_200: `hsl(${RED_HUE}, 91%, 80%)`,
red_400: `hsl(349, 96%, 65%)`, red_300: `hsl(${RED_HUE}, 91%, 70%)`,
red_500: `hsl(349, 96%, 55%)`, red_400: `hsl(${RED_HUE}, 91%, 60%)`,
red_600: `hsl(349, 96%, 45%)`, red_500: `hsl(${RED_HUE}, 91%, 50%)`,
red_700: `hsl(349, 96%, 35%)`, red_600: `hsl(${RED_HUE}, 91%, 42%)`,
red_800: `hsl(349, 96%, 25%)`, red_700: `hsl(${RED_HUE}, 91%, 34%)`,
red_900: `hsl(349, 96%, 15%)`, red_800: `hsl(${RED_HUE}, 91%, 26%)`,
red_1000: `hsl(349, 96%, 5%)`, red_900: `hsl(${RED_HUE}, 91%, 18%)`,
red_950: `hsl(${RED_HUE}, 91%, 10%)`,
red_975: `hsl(${RED_HUE}, 91%, 7%)`,
} as const } as const
export const space = { export const space = {
xxs: 2, _2xs: 2,
xs: 4, xs: 4,
sm: 8, sm: 8,
md: 12, md: 12,
lg: 18, lg: 16,
xl: 24, xl: 20,
xxl: 32, _2xl: 24,
_3xl: 28,
_4xl: 32,
_5xl: 40,
} as const } as const
export const fontSize = { export const fontSize = {
xxs: 10, _2xs: 10,
xs: 12, xs: 12,
sm: 14, sm: 14,
md: 16, md: 16,
lg: 18, lg: 18,
xl: 22, xl: 20,
xxl: 26, _2xl: 22,
_3xl: 26,
_4xl: 32,
_5xl: 40,
} as const } as const
// TODO test
export const lineHeight = { export const lineHeight = {
none: 1, none: 1,
normal: 1.5, normal: 1.5,
@@ -81,6 +97,8 @@ export const lineHeight = {
} as const } as const
export const borderRadius = { export const borderRadius = {
_2xs: 2,
xs: 4,
sm: 8, sm: 8,
md: 12, md: 12,
full: 999, full: 999,
@@ -92,6 +110,56 @@ export const fontWeight = {
bold: '900', bold: '900',
} as const } 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 Color = keyof typeof color
export type Space = keyof typeof space export type Space = keyof typeof space
export type FontSize = keyof typeof fontSize 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 React from 'react'
import {Text as RNText, TextProps} from 'react-native' 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) { export function Text({style, ...rest}: TextProps) {
const t = useTheme() const t = useTheme()
@@ -18,7 +19,7 @@ export function H1({style, ...rest}: TextProps) {
<RNText <RNText
{...attr} {...attr}
{...rest} {...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 <RNText
{...attr} {...attr}
{...rest} {...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 <RNText
{...attr} {...attr}
{...rest} {...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 <RNText
{...attr} {...attr}
{...rest} {...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 <RNText
{...attr} {...attr}
{...rest} {...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 <RNText
{...attr} {...attr}
{...rest} {...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' } from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types' import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session' import {getAgent} from '#/state/session'
import {getContentLanguages} from '#/state/preferences/languages'
export class CustomFeedAPI implements FeedAPI { export class CustomFeedAPI implements FeedAPI {
constructor(public params: GetCustomFeed.QueryParams) {} constructor(public params: GetCustomFeed.QueryParams) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().app.bsky.feed.getFeed({ const contentLangs = getContentLanguages().join(',')
...this.params, const res = await getAgent().app.bsky.feed.getFeed(
limit: 1, {
}) ...this.params,
limit: 1,
},
{headers: {'Accept-Language': contentLangs}},
)
return res.data.feed[0] return res.data.feed[0]
} }
@@ -23,11 +28,15 @@ export class CustomFeedAPI implements FeedAPI {
cursor: string | undefined cursor: string | undefined
limit: number limit: number
}): Promise<FeedAPIResponse> { }): Promise<FeedAPIResponse> {
const res = await getAgent().app.bsky.feed.getFeed({ const contentLangs = getContentLanguages().join(',')
...this.params, const res = await getAgent().app.bsky.feed.getFeed(
cursor, {
limit, ...this.params,
}) cursor,
limit,
},
{headers: {'Accept-Language': contentLangs}},
)
if (res.success) { if (res.success) {
// NOTE // NOTE
// some custom feeds fail to enforce the pagination limit // 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 {FeedParams} from '#/state/queries/post-feed'
import {FeedTunerFn} from '../feed-manip' import {FeedTunerFn} from '../feed-manip'
import {getAgent} from '#/state/session' import {getAgent} from '#/state/session'
import {getContentLanguages} from '#/state/preferences/languages'
const REQUEST_WAIT_MS = 500 // 500ms const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@@ -25,7 +26,7 @@ export class MergeFeedAPI implements FeedAPI {
reset() { reset() {
this.following = new MergeFeedSource_Following(this.feedTuners) 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.feedCursor = 0
this.itemCursor = 0 this.itemCursor = 0
this.sampleCursor = 0 this.sampleCursor = 0
@@ -231,11 +232,15 @@ class MergeFeedSource_Custom extends MergeFeedSource {
limit: number, limit: number,
): Promise<AppBskyFeedGetTimeline.Response> { ): Promise<AppBskyFeedGetTimeline.Response> {
try { try {
const res = await getAgent().app.bsky.feed.getFeed({ const contentLangs = getContentLanguages().join(',')
cursor, const res = await getAgent().app.bsky.feed.getFeed(
limit, {
feed: this.feedUri, cursor,
}) limit,
feed: this.feedUri,
},
{headers: {'Accept-Language': contentLangs}},
)
// NOTE // NOTE
// some custom feeds fail to enforce the pagination limit // some custom feeds fail to enforce the pagination limit
// so we manually truncate here // 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 // youtube
if (urlp.hostname === 'youtu.be') { if (urlp.hostname === 'youtu.be') {
const videoId = urlp.pathname.split('/')[1] const videoId = urlp.pathname.split('/')[1]
const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
if (videoId) { if (videoId) {
return { return {
type: 'youtube_video', type: 'youtube_video',
source: 'youtube', 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 [_, page, shortVideoId] = urlp.pathname.split('/')
const videoId = const videoId =
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string) page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
if (videoId) { if (videoId) {
return { return {
type: page === 'shorts' ? 'youtube_short' : 'youtube_video', type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
source: page === 'shorts' ? 'youtubeShorts' : 'youtube', source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
hideDetails: page === 'shorts' ? true : undefined, 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 type {Theme} from './ThemeContext'
import {colors} from './styles' import {colors} from './styles'
import {darkPalette, lightPalette} from '#/alf/themes'
export const defaultTheme: Theme = { export const defaultTheme: Theme = {
colorScheme: 'light', colorScheme: 'light',
palette: { palette: {
default: { default: {
background: colors.white, background: lightPalette.white,
backgroundLight: colors.gray1, backgroundLight: lightPalette.contrast_50,
text: colors.black, text: lightPalette.black,
textLight: colors.gray5, textLight: lightPalette.contrast_700,
textInverted: colors.white, textInverted: lightPalette.white,
link: colors.blue3, link: lightPalette.primary_500,
border: '#f0e9e9', border: lightPalette.contrast_100,
borderDark: '#e0d9d9', borderDark: lightPalette.contrast_200,
icon: colors.gray4, icon: lightPalette.contrast_500,
// non-standard // non-standard
textVeryLight: colors.gray4, textVeryLight: lightPalette.contrast_400,
replyLine: colors.gray2, replyLine: lightPalette.contrast_100,
replyLineDot: colors.gray3, replyLineDot: lightPalette.contrast_200,
unreadNotifBg: '#ebf6ff', unreadNotifBg: lightPalette.primary_25,
unreadNotifBorder: colors.blue1, unreadNotifBorder: lightPalette.primary_100,
postCtrl: '#71768A', postCtrl: lightPalette.contrast_500,
brandText: '#0066FF', brandText: lightPalette.primary_500,
emptyStateIcon: '#B6B6C9', emptyStateIcon: lightPalette.contrast_300,
borderLinkHover: '#cac1c1', borderLinkHover: lightPalette.contrast_300,
}, },
primary: { primary: {
background: colors.blue3, background: colors.blue3,
@@ -50,15 +52,15 @@ export const defaultTheme: Theme = {
icon: colors.green4, icon: colors.green4,
}, },
inverted: { inverted: {
background: colors.black, background: darkPalette.black,
backgroundLight: colors.gray6, backgroundLight: darkPalette.contrast_50,
text: colors.white, text: darkPalette.white,
textLight: colors.gray3, textLight: darkPalette.contrast_700,
textInverted: colors.black, textInverted: darkPalette.black,
link: colors.blue2, link: darkPalette.primary_500,
border: colors.gray3, border: darkPalette.contrast_100,
borderDark: colors.gray2, borderDark: darkPalette.contrast_200,
icon: colors.gray5, icon: darkPalette.contrast_500,
}, },
error: { error: {
background: colors.red3, background: colors.red3,
@@ -292,26 +294,26 @@ export const darkTheme: Theme = {
palette: { palette: {
...defaultTheme.palette, ...defaultTheme.palette,
default: { default: {
background: colors.black, background: darkPalette.black,
backgroundLight: colors.gray7, backgroundLight: darkPalette.contrast_50,
text: colors.white, text: darkPalette.white,
textLight: colors.gray3, textLight: darkPalette.contrast_700,
textInverted: colors.black, textInverted: darkPalette.black,
link: colors.blue3, link: darkPalette.primary_500,
border: colors.gray7, border: darkPalette.contrast_100,
borderDark: colors.gray6, borderDark: darkPalette.contrast_200,
icon: colors.gray4, icon: darkPalette.contrast_500,
// non-standard // non-standard
textVeryLight: colors.gray4, textVeryLight: darkPalette.contrast_400,
replyLine: colors.gray5, replyLine: darkPalette.contrast_100,
replyLineDot: colors.gray6, replyLineDot: darkPalette.contrast_200,
unreadNotifBg: colors.blue7, unreadNotifBg: darkPalette.primary_975,
unreadNotifBorder: colors.blue6, unreadNotifBorder: darkPalette.primary_900,
postCtrl: '#707489', postCtrl: darkPalette.contrast_500,
brandText: '#0085ff', brandText: darkPalette.primary_500,
emptyStateIcon: colors.gray4, emptyStateIcon: darkPalette.contrast_300,
borderLinkHover: colors.gray5, borderLinkHover: darkPalette.contrast_300,
}, },
primary: { primary: {
...defaultTheme.palette.primary, ...defaultTheme.palette.primary,
@@ -322,15 +324,15 @@ export const darkTheme: Theme = {
textInverted: colors.green2, textInverted: colors.green2,
}, },
inverted: { inverted: {
background: colors.white, background: lightPalette.white,
backgroundLight: colors.gray2, backgroundLight: lightPalette.contrast_50,
text: colors.black, text: lightPalette.black,
textLight: colors.gray5, textLight: lightPalette.contrast_700,
textInverted: colors.white, textInverted: lightPalette.white,
link: colors.blue3, link: lightPalette.primary_500,
border: colors.gray3, border: lightPalette.contrast_100,
borderDark: colors.gray4, borderDark: lightPalette.contrast_200,
icon: colors.gray1, icon: lightPalette.contrast_500,
}, },
}, },
} }
+10
View File
@@ -22,6 +22,14 @@ export function code3ToCode2(lang: string): string {
return lang 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 { export function codeToLanguageName(lang: string): string {
const lang2 = code3ToCode2(lang) const lang2 = code3ToCode2(lang)
return LANGUAGES_MAP_CODE2[lang2]?.name || lang return LANGUAGES_MAP_CODE2[lang2]?.name || lang
@@ -129,6 +137,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.pt_BR return AppLanguage.pt_BR
case 'uk': case 'uk':
return AppLanguage.uk return AppLanguage.uk
case 'ca':
return AppLanguage.ca
default: default:
continue 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 messagesKo} from '#/locale/locales/ko/messages'
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages' import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
import {messages as messagesUk} from '#/locale/locales/uk/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 {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages' import {AppLanguage} from '#/locale/languages'
@@ -59,6 +60,10 @@ export async function dynamicActivate(locale: AppLanguage) {
i18n.loadAndActivate({locale, messages: messagesUk}) i18n.loadAndActivate({locale, messages: messagesUk})
break break
} }
case AppLanguage.ca: {
i18n.loadAndActivate({locale, messages: messagesCa})
break
}
default: { default: {
i18n.loadAndActivate({locale, messages: messagesEn}) i18n.loadAndActivate({locale, messages: messagesEn})
break break
+8 -1
View File
@@ -49,6 +49,10 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/uk/messages`) mod = await import(`./locales/uk/messages`)
break break
} }
case AppLanguage.ca: {
mod = await import(`./locales/ca/messages`)
break
}
default: { default: {
mod = await import(`./locales/en/messages`) mod = await import(`./locales/en/messages`)
break break
@@ -62,6 +66,9 @@ export async function dynamicActivate(locale: AppLanguage) {
export async function useLocaleLanguage() { export async function useLocaleLanguage() {
const {appLanguage} = useLanguagePrefs() const {appLanguage} = useLanguagePrefs()
useEffect(() => { useEffect(() => {
dynamicActivate(sanitizeAppLanguageSetting(appLanguage)) const sanitizedLanguage = sanitizeAppLanguageSetting(appLanguage)
document.documentElement.lang = sanitizedLanguage
dynamicActivate(sanitizedLanguage)
}, [appLanguage]) }, [appLanguage])
} }
+2
View File
@@ -16,6 +16,7 @@ export enum AppLanguage {
ko = 'ko', ko = 'ko',
pt_BR = 'pt-BR', pt_BR = 'pt-BR',
uk = 'uk', uk = 'uk',
ca = 'ca',
} }
interface AppLanguageConfig { interface AppLanguageConfig {
@@ -35,6 +36,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.ko, name: '한국어'}, {code2: AppLanguage.ko, name: '한국어'},
{code2: AppLanguage.pt_BR, name: 'Português (BR)'}, {code2: AppLanguage.pt_BR, name: 'Português (BR)'},
{code2: AppLanguage.uk, name: 'Українська'}, {code2: AppLanguage.uk, name: 'Українська'},
{code2: AppLanguage.ca, name: 'Catalan'},
] ]
export const LANGUAGES: Language[] = [ 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 onAccept: () => void
} }
export interface InAppBrowserConsentModal {
name: 'in-app-browser-consent'
href: string
}
export type Modal = export type Modal =
// Account // Account
| AddAppPasswordModal | AddAppPasswordModal
@@ -231,6 +236,7 @@ export type Modal =
| ConfirmModal | ConfirmModal
| LinkWarningModal | LinkWarningModal
| EmbedConsentModal | EmbedConsentModal
| InAppBrowserConsentModal
const ModalContext = React.createContext<{ const ModalContext = React.createContext<{
isModalActive: boolean isModalActive: boolean
@@ -26,7 +26,7 @@ test('migrate: fresh install', async () => {
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root') expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
expect(read).toHaveBeenCalledTimes(1) expect(read).toHaveBeenCalledTimes(1)
expect(logger.log).toHaveBeenCalledWith( expect(logger.info).toHaveBeenCalledWith(
'persisted state: no migration needed', 'persisted state: no migration needed',
) )
}) })
@@ -38,7 +38,7 @@ test('migrate: fresh install, existing new storage', async () => {
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root') expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
expect(read).toHaveBeenCalledTimes(1) expect(read).toHaveBeenCalledTimes(1)
expect(logger.log).toHaveBeenCalledWith( expect(logger.info).toHaveBeenCalledWith(
'persisted state: no migration needed', 'persisted state: no migration needed',
) )
}) })
@@ -68,7 +68,7 @@ test('migrate: has legacy data', async () => {
await migrate() await migrate()
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP)) expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
expect(logger.log).toHaveBeenCalledWith( expect(logger.info).toHaveBeenCalledWith(
'persisted state: migrated legacy storage', 'persisted state: migrated legacy storage',
) )
}) })
+2 -2
View File
@@ -164,14 +164,14 @@ export async function migrate() {
if (validate.success) { if (validate.success) {
await write(newData) await write(newData)
logger.log('persisted state: migrated legacy storage') logger.info('persisted state: migrated legacy storage')
} else { } else {
logger.error('persisted state: legacy data failed validation', { logger.error('persisted state: legacy data failed validation', {
error: validate.error, error: validate.error,
}) })
} }
} else { } else {
logger.log('persisted state: no migration needed') logger.info('persisted state: no migration needed')
} }
} catch (e: any) { } catch (e: any) {
logger.error(e, { logger.error(e, {
+2
View File
@@ -53,6 +53,7 @@ export const schema = z.object({
step: z.string(), step: z.string(),
}), }),
hiddenPosts: z.array(z.string()).optional(), // should move to server hiddenPosts: z.array(z.string()).optional(), // should move to server
useInAppBrowser: z.boolean().optional(),
}) })
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>
@@ -84,4 +85,5 @@ export const defaults: Schema = {
step: 'Home', step: 'Home',
}, },
hiddenPosts: [], 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 AltTextRequiredProvider} from '../preferences/alt-text-required'
import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts' import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs' import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
import {Provider as InAppBrowserProvider} from './in-app-browser'
export {useLanguagePrefs, useLanguagePrefsApi} from './languages' export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
export { export {
@@ -20,7 +21,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
<LanguagesProvider> <LanguagesProvider>
<AltTextRequiredProvider> <AltTextRequiredProvider>
<ExternalEmbedsProvider> <ExternalEmbedsProvider>
<HiddenPostsProvider>{children}</HiddenPostsProvider> <HiddenPostsProvider>
<InAppBrowserProvider>{children}</InAppBrowserProvider>
</HiddenPostsProvider>
</ExternalEmbedsProvider> </ExternalEmbedsProvider>
</AltTextRequiredProvider> </AltTextRequiredProvider>
</LanguagesProvider> </LanguagesProvider>
+2 -1
View File
@@ -272,7 +272,8 @@ export function usePinnedFeedsInfos(): {
}, },
}) })
} catch (e) { } catch (e) {
logger.warn(`usePinnedFeedsInfos: failed to fetch ${uri}`, { // expected failure
logger.info(`usePinnedFeedsInfos: failed to fetch ${uri}`, {
error: e, error: e,
}) })
} }
+1 -1
View File
@@ -167,7 +167,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
broadcast.postMessage({event: unreadCountStr}) broadcast.postMessage({event: unreadCountStr})
} catch (e) { } 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 {CustomFeedAPI} from 'lib/api/feed/custom'
import {ListFeedAPI} from 'lib/api/feed/list' import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge' import {MergeFeedAPI} from 'lib/api/feed/merge'
import {HomeFeedAPI} from '#/lib/api/feed/home'
import {logger} from '#/logger' import {logger} from '#/logger'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri' import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri'
@@ -338,7 +339,11 @@ function createApi(
feedTuners: FeedTunerFn[], feedTuners: FeedTunerFn[],
) { ) {
if (feedDesc === 'home') { if (feedDesc === 'home') {
return new MergeFeedAPI(params, feedTuners) if (params.mergeFeedEnabled) {
return new MergeFeedAPI(params, feedTuners)
} else {
return new HomeFeedAPI()
}
} else if (feedDesc === 'following') { } else if (feedDesc === 'following') {
return new FollowingFeedAPI() return new FollowingFeedAPI()
} else if (feedDesc.startsWith('author')) { } else if (feedDesc.startsWith('author')) {
+56 -24
View File
@@ -44,6 +44,8 @@ export type ApiContext = {
password: string password: string
handle: string handle: string
inviteCode?: string inviteCode?: string
verificationPhone?: string
verificationCode?: string
}) => Promise<void> }) => Promise<void>
login: (props: { login: (props: {
service: string service: string
@@ -193,11 +195,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
) )
const clearCurrentAccount = React.useCallback(() => { const clearCurrentAccount = React.useCallback(() => {
logger.debug( logger.warn(`session: clear current account`)
`session: clear current account`,
{},
logger.DebugContext.session,
)
__globalAgent = PUBLIC_BSKY_AGENT __globalAgent = PUBLIC_BSKY_AGENT
queryClient.clear() queryClient.clear()
setStateAndPersist(s => ({ setStateAndPersist(s => ({
@@ -207,7 +205,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}, [setStateAndPersist, queryClient]) }, [setStateAndPersist, queryClient])
const createAccount = React.useCallback<ApiContext['createAccount']>( 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`, { logger.info(`session: creating account`, {
service, service,
handle, handle,
@@ -221,12 +227,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
password, password,
email, email,
inviteCode, inviteCode,
verificationPhone,
verificationCode,
}) })
if (!agent.session) { if (!agent.session) {
throw new Error(`session: createAccount failed to establish a session`) throw new Error(`session: createAccount failed to establish a session`)
} }
/*dont await*/ agent.upsertProfile(_existing => {
return {
displayName: handle,
}
})
const account: SessionAccount = { const account: SessionAccount = {
service: agent.service.toString(), service: agent.service.toString(),
did: agent.session.did, did: agent.session.did,
@@ -322,8 +336,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
) )
const logout = React.useCallback<ApiContext['logout']>(async () => { const logout = React.useCallback<ApiContext['logout']>(async () => {
logger.info(`session: logout`)
clearCurrentAccount() clearCurrentAccount()
logger.debug(`session: logout`, {}, logger.DebugContext.session)
setStateAndPersist(s => { setStateAndPersist(s => {
return { return {
...s, ...s,
@@ -551,30 +565,36 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return persisted.onUpdate(() => { return persisted.onUpdate(() => {
const session = persisted.get('session') 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) { if (session.currentAccount?.did !== state.currentAccount?.did) {
logger.debug( logger.info(`session: persisted onUpdate, switching accounts`, {
`session: switching account`, from: {
{ did: state.currentAccount?.did,
from: { handle: state.currentAccount?.handle,
did: state.currentAccount?.did,
handle: state.currentAccount?.handle,
},
to: {
did: session.currentAccount.did,
handle: session.currentAccount.handle,
},
}, },
logger.DebugContext.session, to: {
) did: session.currentAccount.did,
handle: session.currentAccount.handle,
},
})
initSession(session.currentAccount) 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) { } else if (!session.currentAccount && state.currentAccount) {
logger.debug( logger.debug(
`session: logging out`, `session: persisted onUpdate, logging out`,
{ {
did: state.currentAccount?.did, did: state.currentAccount?.did,
handle: state.currentAccount?.handle, handle: state.currentAccount?.handle,
@@ -582,10 +602,22 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
logger.DebugContext.session, 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() clearCurrentAccount()
} }
setState(s => ({
...s,
accounts: session.accounts,
currentAccount: session.currentAccount,
}))
}) })
}, [state, clearCurrentAccount, initSession]) }, [state, setState, clearCurrentAccount, initSession])
const stateContext = React.useMemo( const stateContext = React.useMemo(
() => ({ () => ({
+2 -1
View File
@@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import {AppBskyEmbedRecord} from '@atproto/api' import {AppBskyEmbedRecord, AppBskyRichtextFacet} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
export interface ComposerOptsPostRef { export interface ComposerOptsPostRef {
@@ -17,6 +17,7 @@ export interface ComposerOptsQuote {
uri: string uri: string
cid: string cid: string
text: string text: string
facets?: AppBskyRichtextFacet.Main[]
indexedAt: string indexedAt: string
author: { author: {
did: string 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, useSetSaveFeedsMutation,
DEFAULT_PROD_FEEDS, DEFAULT_PROD_FEEDS,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {IS_PROD} from '#/lib/constants' import {FEEDBACK_FORM_URL, IS_PROD} from '#/lib/constants'
import {Step1} from './Step1' import {Step1} from './Step1'
import {Step2} from './Step2' import {Step2} from './Step2'
import {Step3} from './Step3' import {Step3} from './Step3'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {TextLink} from '../../util/Link'
export function CreateAccount({onPressBack}: {onPressBack: () => void}) { export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
const {screen} = useAnalytics() const {screen} = useAnalytics()
@@ -117,7 +118,7 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
return ( return (
<LoggedOutLayout <LoggedOutLayout
leadin={`Step ${uiState.step}`} leadin=""
title={_(msg`Create Account`)} title={_(msg`Create Account`)}
description={_(msg`We're so excited to have you join us!`)}> description={_(msg`We're so excited to have you join us!`)}>
<ScrollView testID="createAccount" style={pal.view}> <ScrollView testID="createAccount" style={pal.view}>
@@ -176,6 +177,27 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
</> </>
) : undefined} ) : undefined}
</View> </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}} /> <View style={{height: isTabletOrDesktop ? 50 : 400}} />
</ScrollView> </ScrollView>
</LoggedOutLayout> </LoggedOutLayout>
+189 -156
View File
@@ -1,25 +1,38 @@
import React from 'react' 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 {Text} from 'view/com/util/text/Text'
import {DateInput} from 'view/com/util/forms/DateInput'
import {StepHeader} from './StepHeader' 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 {s} from 'lib/styles'
import {HelpTip} from '../util/HelpTip' import {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput' 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 {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 {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' function sanitizeDate(date: Date): Date {
import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' 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({ export function Step1({
uiState, uiState,
uiDispatch, uiDispatch,
@@ -28,136 +41,175 @@ export function Step1({
uiDispatch: CreateAccountDispatch uiDispatch: CreateAccountDispatch
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
const {_} = useLingui() const {_} = useLingui()
const {openModal} = useModalControls()
const onPressDefault = React.useCallback(() => { const onPressSelectService = React.useCallback(() => {
setIsDefaultSelected(true) openModal({
uiDispatch({type: 'set-service-url', value: PROD_SERVICE}) name: 'server-input',
}, [setIsDefaultSelected, uiDispatch]) initialService: uiState.serviceUrl,
onSelect: (url: string) =>
uiDispatch({type: 'set-service-url', value: url}),
})
Keyboard.dismiss()
}, [uiDispatch, uiState.serviceUrl, openModal])
const onPressOther = React.useCallback(() => { const onPressWaitlist = React.useCallback(() => {
setIsDefaultSelected(false) openModal({name: 'waitlist'})
uiDispatch({type: 'set-service-url', value: 'https://'}) }, [openModal])
}, [setIsDefaultSelected, uiDispatch])
const onChangeServiceUrl = React.useCallback( const birthDate = React.useMemo(() => {
(v: string) => { return sanitizeDate(uiState.birthDate)
uiDispatch({type: 'set-service-url', value: v}) }, [uiState.birthDate])
},
[uiDispatch],
)
return ( return (
<View> <View>
<StepHeader step="1" title={_(msg`Your hosting provider`)} /> <StepHeader uiState={uiState} title={_(msg`Your account`)}>
<Text style={[pal.text, s.mb10]}> <View>
<Trans>This is the service that keeps you online.</Trans> <Button
</Text> testID="selectServiceButton"
<Option type="default"
testID="blueskyServerBtn" style={{
isSelected={isDefaultSelected} aspectRatio: 1,
label="Bluesky" justifyContent: 'center',
help="&nbsp;(default)" alignItems: 'center',
onPress={onPressDefault} }}
/> accessibilityLabel={_(msg`Select service`)}
<Option accessibilityHint={_(msg`Sets server for the Bluesky client`)}
testID="otherServerBtn" onPress={onPressSelectService}>
isSelected={!isDefaultSelected} <FontAwesomeIcon icon="server" size={21} />
label="Other" </Button>
onPress={onPressOther}> </View>
<View style={styles.otherForm}> </StepHeader>
<Text nativeID="addressProvider" style={[pal.text, s.mb5]}>
<Trans>Enter the address of your provider:</Trans> {!uiState.serviceDescription ? (
</Text> <ActivityIndicator />
<TextInput ) : (
testID="customServerInput" <>
icon="globe" {uiState.isInviteCodeRequired && (
placeholder={_(msg`Hosting provider address`)} <View style={s.pb20}>
value={uiState.serviceUrl} <Text type="md-medium" style={[pal.text, s.mb2]}>
editable <Trans>Invite code</Trans>
onChange={onChangeServiceUrl} </Text>
accessibilityHint={_(msg`Input hosting provider address`)} <TextInput
accessibilityLabel={_(msg`Hosting provider address`)} testID="inviteCodeInput"
accessibilityLabelledBy="addressProvider" icon="ticket"
/> placeholder={_(msg`Required for this provider`)}
{LOGIN_INCLUDE_DEV_SERVERS && ( value={uiState.inviteCode}
<View style={[s.flexRow, s.mt10]}> editable
<Button onChange={value => uiDispatch({type: 'set-invite-code', value})}
testID="stagingServerBtn" accessibilityLabel={_(msg`Invite code`)}
type="default" accessibilityHint={_(msg`Input invite code to proceed`)}
style={s.mr5} autoCapitalize="none"
label={_(msg`Staging`)} autoComplete="off"
onPress={() => onChangeServiceUrl(STAGING_SERVICE)} autoCorrect={false}
/> autoFocus={true}
<Button
testID="localDevServerBtn"
type="default"
label={_(msg`Dev Server`)}
onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)}
/> />
</View> </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 ? ( {uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} /> <ErrorMessage message={uiState.error} style={styles.error} />
) : ( ) : undefined}
<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}
</View> </View>
) )
} }
@@ -165,34 +217,15 @@ function Option({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
error: { error: {
borderRadius: 6, borderRadius: 6,
marginTop: 10,
}, },
dateInputButton: {
option: {
borderWidth: 1, borderWidth: 1,
borderRadius: 6, borderRadius: 6,
marginBottom: 10, paddingVertical: 14,
}, },
optionHeading: { // @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.
flexDirection: 'row', touchable: {
alignItems: 'center', ...(isWeb && {cursor: 'pointer'}),
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,
}, },
}) })
+248 -135
View File
@@ -1,39 +1,34 @@
import React from 'react' import React from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' import {
import {CreateAccountState, CreateAccountDispatch, is18} from './state' 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 {Text} from 'view/com/util/text/Text'
import {DateInput} from 'view/com/util/forms/DateInput'
import {StepHeader} from './StepHeader' import {StepHeader} from './StepHeader'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput' import {TextInput} from '../util/TextInput'
import {Policies} from './Policies' import {Button} from '../../util/forms/Button'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 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 {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {logger} from '#/logger' 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({ export function Step2({
uiState, uiState,
uiDispatch, uiDispatch,
@@ -43,130 +38,253 @@ export function Step2({
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {openModal} = useModalControls() const {isMobile} = useWebMediaQueries()
const onPressWaitlist = React.useCallback(() => { const onPressRequest = React.useCallback(() => {
openModal({name: 'waitlist'}) if (
}, [openModal]) 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(() => { const onPressRetry = React.useCallback(() => {
return sanitizeDate(uiState.birthDate) uiDispatch({type: 'set-has-requested-verification-code', value: false})
}, [uiState.birthDate]) }, [uiDispatch])
const phoneNumberFormatted = React.useMemo(
() =>
uiState.hasRequestedVerificationCode
? parsePhoneNumber(
uiState.verificationPhone,
uiState.phoneCountry,
)?.formatInternational()
: '',
[
uiState.hasRequestedVerificationCode,
uiState.verificationPhone,
uiState.phoneCountry,
],
)
return ( return (
<View> <View>
<StepHeader step="2" title={_(msg`Your account`)} /> <StepHeader uiState={uiState} title={_(msg`SMS verification`)} />
{uiState.isInviteCodeRequired && ( {!uiState.hasRequestedVerificationCode ? (
<View style={s.pb20}> <>
<Text type="md-medium" style={[pal.text, s.mb2]}> <View style={s.pb10}>
<Trans>Invite code</Trans> <Text
</Text> type="md-medium"
<TextInput style={[pal.text, s.mb2]}
testID="inviteCodeInput" nativeID="phoneCountry">
icon="ticket" <Trans>Country</Trans>
placeholder={_(msg`Required for this provider`)} </Text>
value={uiState.inviteCode} <View
editable style={[
onChange={value => uiDispatch({type: 'set-invite-code', value})} {position: 'relative'},
accessibilityLabel={_(msg`Invite code`)} isAndroid && {
accessibilityHint={_(msg`Input invite code to proceed`)} borderWidth: 1,
autoCapitalize="none" borderColor: pal.border.borderColor,
autoComplete="off" borderRadius: 4,
autoCorrect={false} },
/> ]}>
</View> <RNPickerSelect
)} placeholder={{}}
value={uiState.phoneCountry}
{!uiState.inviteCode && uiState.isInviteCodeRequired ? ( onValueChange={value =>
<Text style={[s.alignBaseline, pal.text]}> uiDispatch({type: 'set-phone-country', value})
<Trans>Don't have an invite code?</Trans>{' '} }
<TouchableWithoutFeedback items={COUNTRY_CODES.filter(l => Boolean(l.code2)).map(l => ({
onPress={onPressWaitlist} label: l.name,
accessibilityLabel={_(msg`Join the waitlist.`)} value: l.code2,
accessibilityHint=""> key: l.code2,
<View style={styles.touchable}> }))}
<Text style={pal.link}> style={{
<Trans>Join the waitlist.</Trans> inputAndroid: {
</Text> 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> </View>
</TouchableWithoutFeedback> </View>
</Text>
<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}> <View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]} nativeID="email"> <View
<Trans>Email address</Trans> style={[
</Text> 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 <TextInput
testID="emailInput" testID="codeInput"
icon="envelope" icon="hashtag"
placeholder={_(msg`Enter your email address`)} placeholder={_(msg`XXXXXX`)}
value={uiState.email} value={uiState.verificationCode}
editable editable
onChange={value => uiDispatch({type: 'set-email', value})} onChange={value =>
uiDispatch({type: 'set-verification-code', value})
}
accessibilityLabel={_(msg`Email`)} accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(msg`Input email for Bluesky waitlist`)} accessibilityHint={_(
accessibilityLabelledBy="email" msg`Input the verification code we have texted to you`,
)}
accessibilityLabelledBy="verificationCode"
keyboardType="phone-pad"
autoCapitalize="none" autoCapitalize="none"
autoComplete="off" autoComplete="one-time-code"
textContentType="oneTimeCode"
autoCorrect={false} autoCorrect={false}
autoFocus={true}
/> />
</View> <Text type="sm" style={[pal.textLight, s.mt5]}>
<Trans>
<View style={s.pb20}> Please enter the verification code sent to{' '}
<Text {phoneNumberFormatted}.
type="md-medium" </Trans>
style={[pal.text, s.mb2]}
nativeID="password">
<Trans>Password</Trans>
</Text> </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>
<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 ? ( {uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} /> <ErrorMessage message={uiState.error} style={styles.error} />
) : undefined} ) : undefined}
@@ -179,11 +297,6 @@ const styles = StyleSheet.create({
borderRadius: 6, borderRadius: 6,
marginTop: 10, 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. // @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: { touchable: {
...(isWeb && {cursor: 'pointer'}), ...(isWeb && {cursor: 'pointer'}),
+1 -1
View File
@@ -25,7 +25,7 @@ export function Step3({
const {_} = useLingui() const {_} = useLingui()
return ( return (
<View> <View>
<StepHeader step="3" title={_(msg`Your user handle`)} /> <StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
<View style={s.pb10}> <View style={s.pb10}>
<TextInput <TextInput
testID="handleInput" 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 {Text} from 'view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {Trans} from '@lingui/macro' 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 pal = usePalette('default')
const numSteps = uiState.isPhoneVerificationRequired ? 3 : 2
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text type="lg" style={[pal.textLight]}> <View>
{step === '3' ? ( <Text type="lg" style={[pal.textLight]}>
<Trans>Last step!</Trans> {uiState.step === 3 ? (
) : ( <Trans>Last step!</Trans>
<Trans>Step {step} of 3</Trans> ) : (
)} <Trans>
</Text> Step {uiState.step} of {numSteps}
<Text style={[pal.text]} type="title-xl"> </Trans>
{title} )}
</Text> </Text>
<Text style={[pal.text]} type="title-xl">
{title}
</Text>
</View>
{children}
</View> </View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20, marginBottom: 20,
}, },
}) })
+108 -8
View File
@@ -2,6 +2,7 @@ import {useReducer} from 'react'
import { import {
ComAtprotoServerDescribeServer, ComAtprotoServerDescribeServer,
ComAtprotoServerCreateAccount, ComAtprotoServerCreateAccount,
BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import {I18nContext, useLingui} from '@lingui/react' import {I18nContext, useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
@@ -13,6 +14,7 @@ import {cleanError} from '#/lib/strings/errors'
import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
import {ApiContext as SessionApiContext} from '#/state/session' import {ApiContext as SessionApiContext} from '#/state/session'
import {DEFAULT_SERVICE} from '#/lib/constants' import {DEFAULT_SERVICE} from '#/lib/constants'
import parsePhoneNumber, {CountryCode} from 'libphonenumber-js'
export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago 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-invite-code'; value: string}
| {type: 'set-email'; value: string} | {type: 'set-email'; value: string}
| {type: 'set-password'; 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-handle'; value: string}
| {type: 'set-birth-date'; value: Date} | {type: 'set-birth-date'; value: Date}
| {type: 'next'} | {type: 'next'}
@@ -43,6 +49,10 @@ export interface CreateAccountState {
inviteCode: string inviteCode: string
email: string email: string
password: string password: string
phoneCountry: CountryCode
verificationPhone: string
verificationCode: string
hasRequestedVerificationCode: boolean
handle: string handle: string
birthDate: Date birthDate: Date
@@ -50,6 +60,7 @@ export interface CreateAccountState {
canBack: boolean canBack: boolean
canNext: boolean canNext: boolean
isInviteCodeRequired: boolean isInviteCodeRequired: boolean
isPhoneVerificationRequired: boolean
} }
export type CreateAccountDispatch = (action: CreateAccountAction) => void export type CreateAccountDispatch = (action: CreateAccountAction) => void
@@ -66,15 +77,55 @@ export function useCreateAccount() {
inviteCode: '', inviteCode: '',
email: '', email: '',
password: '', password: '',
phoneCountry: 'US',
verificationPhone: '',
verificationCode: '',
hasRequestedVerificationCode: false,
handle: '', handle: '',
birthDate: DEFAULT_DATE, birthDate: DEFAULT_DATE,
canBack: false, canBack: false,
canNext: false, canNext: false,
isInviteCodeRequired: 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({ export async function submit({
createAccount, createAccount,
onboardingDispatch, onboardingDispatch,
@@ -89,26 +140,36 @@ export async function submit({
_: I18nContext['_'] _: I18nContext['_']
}) { }) {
if (!uiState.email) { if (!uiState.email) {
uiDispatch({type: 'set-step', value: 2}) uiDispatch({type: 'set-step', value: 1})
return uiDispatch({ return uiDispatch({
type: 'set-error', type: 'set-error',
value: _(msg`Please enter your email.`), value: _(msg`Please enter your email.`),
}) })
} }
if (!EmailValidator.validate(uiState.email)) { if (!EmailValidator.validate(uiState.email)) {
uiDispatch({type: 'set-step', value: 2}) uiDispatch({type: 'set-step', value: 1})
return uiDispatch({ return uiDispatch({
type: 'set-error', type: 'set-error',
value: _(msg`Your email appears to be invalid.`), value: _(msg`Your email appears to be invalid.`),
}) })
} }
if (!uiState.password) { if (!uiState.password) {
uiDispatch({type: 'set-step', value: 2}) uiDispatch({type: 'set-step', value: 1})
return uiDispatch({ return uiDispatch({
type: 'set-error', type: 'set-error',
value: _(msg`Please choose your password.`), 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) { if (!uiState.handle) {
uiDispatch({type: 'set-step', value: 3}) uiDispatch({type: 'set-step', value: 3})
return uiDispatch({ return uiDispatch({
@@ -127,6 +188,8 @@ export async function submit({
handle: createFullHandle(uiState.handle, uiState.userDomain), handle: createFullHandle(uiState.handle, uiState.userDomain),
password: uiState.password, password: uiState.password,
inviteCode: uiState.inviteCode.trim(), inviteCode: uiState.inviteCode.trim(),
verificationPhone: uiState.verificationPhone.trim(),
verificationCode: uiState.verificationCode.trim(),
}) })
} catch (e: any) { } catch (e: any) {
onboardingDispatch({type: 'skip'}) // undo starting the onboard onboardingDispatch({type: 'skip'}) // undo starting the onboard
@@ -135,6 +198,9 @@ export async function submit({
errMsg = _( errMsg = _(
msg`Invite code not accepted. Check that you input it correctly and try again.`, 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)) { if ([400, 429].includes(e.status)) {
@@ -201,6 +267,22 @@ function createReducer({_}: {_: I18nContext['_']}) {
case 'set-password': { case 'set-password': {
return compute({...state, password: action.value}) 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': { case 'set-handle': {
return compute({...state, handle: action.value}) return compute({...state, handle: action.value})
} }
@@ -208,7 +290,7 @@ function createReducer({_}: {_: I18nContext['_']}) {
return compute({...state, birthDate: action.value}) return compute({...state, birthDate: action.value})
} }
case 'next': { case 'next': {
if (state.step === 2) { if (state.step === 1) {
if (!is13(state)) { if (!is13(state)) {
return compute({ return compute({
...state, ...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': { 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 { function compute(state: CreateAccountState): CreateAccountState {
let canNext = true let canNext = true
if (state.step === 1) { if (state.step === 1) {
canNext = !!state.serviceDescription
} else if (state.step === 2) {
canNext = canNext =
!!state.serviceDescription &&
(!state.isInviteCodeRequired || !!state.inviteCode) && (!state.isInviteCodeRequired || !!state.inviteCode) &&
!!state.email && !!state.email &&
!!state.password !!state.password
} else if (state.step === 2) {
canNext =
!state.isPhoneVerificationRequired ||
(!!state.verificationPhone &&
isValidVerificationCode(state.verificationCode))
} else if (state.step === 3) { } else if (state.step === 3) {
canNext = !!state.handle canNext = !!state.handle
} }
@@ -244,5 +338,11 @@ function compute(state: CreateAccountState): CreateAccountState {
canBack: state.step > 1, canBack: state.step > 1,
canNext, canNext,
isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired, 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 {MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {LabelsBtn} from './labels/LabelsBtn' import {LabelsBtn} from './labels/LabelsBtn'
import {SelectLangBtn} from './select-language/SelectLangBtn' import {SelectLangBtn} from './select-language/SelectLangBtn'
import {SuggestedLanguage} from './select-language/SuggestedLanguage'
import {insertMentionAt} from 'lib/strings/mention-manip' import {insertMentionAt} from 'lib/strings/mention-manip'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -73,7 +74,7 @@ export const ComposePost = observer(function ComposePost({
}: Props) { }: Props) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
const {activeModals} = useModals() const {isModalActive, activeModals} = useModals()
const {openModal, closeModal} = useModalControls() const {openModal, closeModal} = useModalControls()
const {closeComposer} = useComposerControls() const {closeComposer} = useComposerControls()
const {track} = useAnalytics() const {track} = useAnalytics()
@@ -175,11 +176,11 @@ export const ComposePost = observer(function ComposePost({
[onPressCancel], [onPressCancel],
) )
useEffect(() => { useEffect(() => {
if (isWeb) { if (isWeb && !isModalActive) {
window.addEventListener('keydown', onEscape) window.addEventListener('keydown', onEscape)
return () => window.removeEventListener('keydown', onEscape) return () => window.removeEventListener('keydown', onEscape)
} }
}, [onEscape]) }, [onEscape, isModalActive])
const onPressAddLinkCard = useCallback( const onPressAddLinkCard = useCallback(
(uri: string) => { (uri: string) => {
@@ -454,6 +455,7 @@ export const ComposePost = observer(function ComposePost({
))} ))}
</View> </View>
) : null} ) : null}
<SuggestedLanguage text={richtext.text} />
<View style={[pal.border, styles.bottomBar]}> <View style={[pal.border, styles.bottomBar]}>
{canSelectImages ? ( {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 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 {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import ImageView from './ImageViewing' import ImageView from './ImageViewing'
import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip' import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip'
@@ -107,12 +107,16 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
{altText ? ( {altText ? (
<Pressable <Pressable
onPress={() => setAltExpanded(!isAltExpanded)} onPress={() => setAltExpanded(!isAltExpanded)}
onLongPress={() => {}}
accessibilityRole="button"> accessibilityRole="button">
<Text <View>
style={[s.gray3, styles.footerText]} <Text
numberOfLines={isAltExpanded ? undefined : 3}> selectable
{altText} style={[s.gray3, styles.footerText]}
</Text> numberOfLines={isAltExpanded ? undefined : 3}>
{altText}
</Text>
</View>
</Pressable> </Pressable>
) : null} ) : null}
<View style={styles.footerBtns}> <View style={styles.footerBtns}>
+13 -5
View File
@@ -94,15 +94,23 @@ export const ListCard = ({
</Trans> </Trans>
))} ))}
</Text> </Text>
{!!list.viewer?.muted && ( <View style={s.flexRow}>
<View style={s.flexRow}> {list.viewer?.muted ? (
<View style={[s.mt5, pal.btn, styles.pill]}> <View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}> <Text type="xs" style={pal.text}>
<Trans>Subscribed</Trans> <Trans>Muted</Trans>
</Text> </Text>
</View> </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> </View>
{renderButton ? ( {renderButton ? (
<View style={styles.layoutButton}>{renderButton()}</View> <View style={styles.layoutButton}>{renderButton()}</View>

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