Merge branch 'main' into feat/selectable-text

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

After

Width:  |  Height:  |  Size: 260 B

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

After

Width:  |  Height:  |  Size: 512 B

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

After

Width:  |  Height:  |  Size: 1011 B

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

After

Width:  |  Height:  |  Size: 1005 B

+1 -1
View File
@@ -80,7 +80,7 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
}
}
af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, did.String(), "", "", 30)
af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, did.String(), "", "posts_no_replies", 30)
if err != nil {
log.Warn("failed to fetch author feed", "did", did, "err", err)
return err
+25 -6
View File
@@ -39,30 +39,49 @@
height: calc(100% + env(safe-area-inset-top));
}
/* Remove autofill styles on Webkit */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
border: 0;
-webkit-text-fill-color: transparent;
-webkit-box-shadow: none;
}
/* Force left-align date/time inputs on iOS mobile */
input::-webkit-date-and-time-value {
text-align: left;
}
/* Color theming */
:root {
--text: black;
--background: white;
--backgroundLight: #F3F3F8;
--backgroundLight: hsl(211, 20%, 95%);
}
html.colorMode--dark {
--text: white;
--background: black;
--backgroundLight: #26272D;
--background: hsl(211, 20%, 4%);
--backgroundLight: hsl(211, 20%, 20%);
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
html.colorMode--system {
--text: black;
--background: white;
--backgroundLight: #F3F3F8;
--backgroundLight: hsl(211, 20%, 95%);
}
}
@media (prefers-color-scheme: dark) {
html.colorMode--system {
--text: white;
--background: black;
--backgroundLight: #26272D;
--background: hsl(211, 20%, 4%);
--backgroundLight: hsl(211, 20%, 20%);
color-scheme: dark;
}
}
+35 -3
View File
@@ -1,7 +1,7 @@
import net from 'net'
import path from 'path'
import fs from 'fs'
import {TestNetwork} from '@atproto/dev-env'
import {TestNetwork, TestPds} from '@atproto/dev-env'
import {AtUri, BskyAgent} from '@atproto/api'
export interface TestUser {
@@ -55,19 +55,36 @@ class StringIdGenerator {
const ids = new StringIdGenerator()
export async function createServer(
{inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false},
{
inviteRequired,
phoneRequired,
}: {inviteRequired: boolean; phoneRequired: boolean} = {
inviteRequired: false,
phoneRequired: false,
},
): Promise<TestPDS> {
const port = await getPort()
const port = 3000
const port2 = await getPort(port + 1)
const port3 = await getPort(port2 + 1)
const pdsUrl = `http://localhost:${port}`
const id = ids.next()
const phoneParams = phoneRequired
? {
phoneVerificationRequired: true,
twilioAccountSid: 'ACXXXXXXX',
twilioAuthToken: 'AUTH',
twilioServiceSid: 'VAXXXXXXXX',
}
: {}
const testNet = await TestNetwork.create({
pds: {
port,
hostname: 'localhost',
dbPostgresSchema: `pds_${id}`,
inviteRequired,
...phoneParams,
},
bsky: {
dbPostgresSchema: `bsky_${id}`,
@@ -76,6 +93,7 @@ export async function createServer(
},
plc: {port: port2},
})
mockTwilio(testNet.pds)
const pic = fs.readFileSync(
path.join(__dirname, '..', 'assets', 'default-avatar.png'),
@@ -144,6 +162,8 @@ class Mocker {
email,
handle: name + '.test',
password: 'hunter2',
verificationPhone: '1234567890',
verificationCode: '000000',
})
await agent.upsertProfile(async () => {
const blob = await agent.uploadBlob(this.pic, {
@@ -430,3 +450,15 @@ async function getPort(start = 3000) {
}
throw new Error('Unable to find an available port')
}
export const mockTwilio = (pds: TestPds) => {
if (!pds.ctx.twilio) return
pds.ctx.twilio.sendCode = async (_number: string) => {
// do nothing
}
pds.ctx.twilio.verifyCode = async (_number: string, code: string) => {
return code === '000000'
}
}
+13 -1
View File
@@ -1,6 +1,18 @@
/** @type {import('@lingui/conf').LinguiConfig} */
module.exports = {
locales: ['en', 'de', 'es', 'fr', 'hi', 'id', 'ja', 'ko', 'pt-BR', 'uk'],
locales: [
'en',
'de',
'es',
'fr',
'hi',
'id',
'ja',
'ko',
'pt-BR',
'uk',
'ca',
],
catalogs: [
{
path: '<rootDir>/src/locale/locales/{locale}/messages',
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.64.0",
"version": "1.65.0",
"private": true,
"engines": {
"node": ">=18"
@@ -39,7 +39,7 @@
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
},
"dependencies": {
"@atproto/api": "^0.8.0",
"@atproto/api": "^0.9.1",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1",
@@ -70,6 +70,7 @@
"@segment/analytics-react-native": "^2.10.1",
"@segment/sovran-react-native": "^0.4.5",
"@sentry/react-native": "5.5.0",
"@tamagui/focus-scope": "^1.84.1",
"@tanstack/react-query": "^5.8.1",
"@tiptap/core": "^2.0.0-beta.220",
"@tiptap/extension-document": "^2.0.0-beta.220",
@@ -119,6 +120,7 @@
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
"libphonenumber-js": "^1.10.53",
"lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
+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";
+35 -24
View File
@@ -13,6 +13,8 @@ import {
import 'view/icons'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {init as initPersistedState} from '#/state/persisted'
import {listenSessionDropped} from './state/events'
import {useColorMode} from 'state/shell'
@@ -25,6 +27,7 @@ import {queryClient} from 'lib/react-query'
import {TestCtrls} from 'view/com/testing/TestCtrls'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as DialogStateProvider} from 'state/dialogs'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
@@ -39,6 +42,7 @@ import {
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {Splash} from '#/Splash'
import {Provider as PortalProvider} from '#/components/Portal'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -48,6 +52,7 @@ function InnerApp() {
const colorMode = useColorMode()
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme(colorMode)
const {_} = useLingui()
// init
@@ -63,25 +68,27 @@ function InnerApp() {
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Splash isReady={!isInitialLoad}>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
</LoggedOutViewProvider>
</React.Fragment>
</Splash>
<Alf theme={theme}>
<Splash isReady={!isInitialLoad}>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
</LoggedOutViewProvider>
</React.Fragment>
</Splash>
</Alf>
</SafeAreaProvider>
)
}
@@ -109,11 +116,15 @@ function App() {
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<I18nProvider>
<InnerApp />
</I18nProvider>
</LightboxStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
+12 -6
View File
@@ -8,6 +8,7 @@ import {RootSiblingParent} from 'react-native-root-siblings'
import 'view/icons'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell'
import {Shell} from 'view/shell/index'
@@ -16,6 +17,7 @@ import {ThemeProvider} from 'lib/ThemeContext'
import {queryClient} from 'lib/react-query'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as DialogStateProvider} from 'state/dialogs'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
@@ -29,7 +31,7 @@ import {
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
function InnerApp() {
const {isInitialLoad, currentAccount} = useSession()
@@ -92,11 +94,15 @@ function App() {
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<I18nProvider>
<InnerApp />
</I18nProvider>
</LightboxStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
+4 -4
View File
@@ -61,7 +61,7 @@ import {ProfileListScreen} from './view/screens/ProfileList'
import {PostThreadScreen} from './view/screens/PostThread'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {DebugScreen} from './view/screens/DebugNew'
import {Storybook} from './view/screens/Storybook'
import {LogScreen} from './view/screens/Log'
import {SupportScreen} from './view/screens/Support'
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
@@ -144,7 +144,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
name="Profile"
getComponent={() => ProfileScreen}
options={({route}) => ({
title: title(msg`@${route.params.name}`),
title: bskyTitle(`@${route.params.name}`, unreadCountLabel),
animation: 'none',
})}
/>
@@ -200,8 +200,8 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
/>
<Stack.Screen
name="Debug"
getComponent={() => DebugScreen}
options={{title: title(msg`Debug`), requireAuth: true}}
getComponent={() => Storybook}
options={{title: title(msg`Storybook`), requireAuth: true}}
/>
<Stack.Screen
name="Log"
+266 -78
View File
@@ -4,6 +4,9 @@ export const atoms = {
/*
* Positioning
*/
fixed: {
position: 'fixed',
},
absolute: {
position: 'absolute',
},
@@ -32,6 +35,10 @@ export const atoms = {
zIndex: 50,
},
overflow_hidden: {
overflow: 'hidden',
},
/*
* Width
*/
@@ -45,6 +52,12 @@ export const atoms = {
/*
* Border radius
*/
rounded_2xs: {
borderRadius: tokens.borderRadius._2xs,
},
rounded_xs: {
borderRadius: tokens.borderRadius.xs,
},
rounded_sm: {
borderRadius: tokens.borderRadius.sm,
},
@@ -58,8 +71,8 @@ export const atoms = {
/*
* Flex
*/
gap_xxs: {
gap: tokens.space.xxs,
gap_2xs: {
gap: tokens.space._2xs,
},
gap_xs: {
gap: tokens.space.xs,
@@ -76,8 +89,17 @@ export const atoms = {
gap_xl: {
gap: tokens.space.xl,
},
gap_xxl: {
gap: tokens.space.xxl,
gap_2xl: {
gap: tokens.space._2xl,
},
gap_3xl: {
gap: tokens.space._3xl,
},
gap_4xl: {
gap: tokens.space._4xl,
},
gap_5xl: {
gap: tokens.space._5xl,
},
flex: {
display: 'flex',
@@ -125,9 +147,9 @@ export const atoms = {
text_right: {
textAlign: 'right',
},
text_xxs: {
fontSize: tokens.fontSize.xxs,
lineHeight: tokens.fontSize.xxs,
text_2xs: {
fontSize: tokens.fontSize._2xs,
lineHeight: tokens.fontSize._2xs,
},
text_xs: {
fontSize: tokens.fontSize.xs,
@@ -149,9 +171,21 @@ export const atoms = {
fontSize: tokens.fontSize.xl,
lineHeight: tokens.fontSize.xl,
},
text_xxl: {
fontSize: tokens.fontSize.xxl,
lineHeight: tokens.fontSize.xxl,
text_2xl: {
fontSize: tokens.fontSize._2xl,
lineHeight: tokens.fontSize._2xl,
},
text_3xl: {
fontSize: tokens.fontSize._3xl,
lineHeight: tokens.fontSize._3xl,
},
text_4xl: {
fontSize: tokens.fontSize._4xl,
lineHeight: tokens.fontSize._4xl,
},
text_5xl: {
fontSize: tokens.fontSize._5xl,
lineHeight: tokens.fontSize._5xl,
},
leading_tight: {
lineHeight: 1.25,
@@ -162,11 +196,8 @@ export const atoms = {
font_normal: {
fontWeight: tokens.fontWeight.normal,
},
font_semibold: {
fontWeight: tokens.fontWeight.semibold,
},
font_bold: {
fontWeight: tokens.fontWeight.bold,
fontWeight: tokens.fontWeight.semibold,
},
/*
@@ -182,11 +213,30 @@ export const atoms = {
borderBottomWidth: 1,
},
/*
* Shadow
*/
shadow_sm: {
shadowRadius: 8,
shadowOpacity: 0.1,
elevation: 8,
},
shadow_md: {
shadowRadius: 16,
shadowOpacity: 0.1,
elevation: 16,
},
shadow_lg: {
shadowRadius: 32,
shadowOpacity: 0.1,
elevation: 24,
},
/*
* Padding
*/
p_xxs: {
padding: tokens.space.xxs,
p_2xs: {
padding: tokens.space._2xs,
},
p_xs: {
padding: tokens.space.xs,
@@ -203,12 +253,21 @@ export const atoms = {
p_xl: {
padding: tokens.space.xl,
},
p_xxl: {
padding: tokens.space.xxl,
p_2xl: {
padding: tokens.space._2xl,
},
px_xxs: {
paddingLeft: tokens.space.xxs,
paddingRight: tokens.space.xxs,
p_3xl: {
padding: tokens.space._3xl,
},
p_4xl: {
padding: tokens.space._4xl,
},
p_5xl: {
padding: tokens.space._5xl,
},
px_2xs: {
paddingLeft: tokens.space._2xs,
paddingRight: tokens.space._2xs,
},
px_xs: {
paddingLeft: tokens.space.xs,
@@ -230,13 +289,25 @@ export const atoms = {
paddingLeft: tokens.space.xl,
paddingRight: tokens.space.xl,
},
px_xxl: {
paddingLeft: tokens.space.xxl,
paddingRight: tokens.space.xxl,
px_2xl: {
paddingLeft: tokens.space._2xl,
paddingRight: tokens.space._2xl,
},
py_xxs: {
paddingTop: tokens.space.xxs,
paddingBottom: tokens.space.xxs,
px_3xl: {
paddingLeft: tokens.space._3xl,
paddingRight: tokens.space._3xl,
},
px_4xl: {
paddingLeft: tokens.space._4xl,
paddingRight: tokens.space._4xl,
},
px_5xl: {
paddingLeft: tokens.space._5xl,
paddingRight: tokens.space._5xl,
},
py_2xs: {
paddingTop: tokens.space._2xs,
paddingBottom: tokens.space._2xs,
},
py_xs: {
paddingTop: tokens.space.xs,
@@ -258,12 +329,24 @@ export const atoms = {
paddingTop: tokens.space.xl,
paddingBottom: tokens.space.xl,
},
py_xxl: {
paddingTop: tokens.space.xxl,
paddingBottom: tokens.space.xxl,
py_2xl: {
paddingTop: tokens.space._2xl,
paddingBottom: tokens.space._2xl,
},
pt_xxs: {
paddingTop: tokens.space.xxs,
py_3xl: {
paddingTop: tokens.space._3xl,
paddingBottom: tokens.space._3xl,
},
py_4xl: {
paddingTop: tokens.space._4xl,
paddingBottom: tokens.space._4xl,
},
py_5xl: {
paddingTop: tokens.space._5xl,
paddingBottom: tokens.space._5xl,
},
pt_2xs: {
paddingTop: tokens.space._2xs,
},
pt_xs: {
paddingTop: tokens.space.xs,
@@ -280,11 +363,20 @@ export const atoms = {
pt_xl: {
paddingTop: tokens.space.xl,
},
pt_xxl: {
paddingTop: tokens.space.xxl,
pt_2xl: {
paddingTop: tokens.space._2xl,
},
pb_xxs: {
paddingBottom: tokens.space.xxs,
pt_3xl: {
paddingTop: tokens.space._3xl,
},
pt_4xl: {
paddingTop: tokens.space._4xl,
},
pt_5xl: {
paddingTop: tokens.space._5xl,
},
pb_2xs: {
paddingBottom: tokens.space._2xs,
},
pb_xs: {
paddingBottom: tokens.space.xs,
@@ -301,11 +393,20 @@ export const atoms = {
pb_xl: {
paddingBottom: tokens.space.xl,
},
pb_xxl: {
paddingBottom: tokens.space.xxl,
pb_2xl: {
paddingBottom: tokens.space._2xl,
},
pl_xxs: {
paddingLeft: tokens.space.xxs,
pb_3xl: {
paddingBottom: tokens.space._3xl,
},
pb_4xl: {
paddingBottom: tokens.space._4xl,
},
pb_5xl: {
paddingBottom: tokens.space._5xl,
},
pl_2xs: {
paddingLeft: tokens.space._2xs,
},
pl_xs: {
paddingLeft: tokens.space.xs,
@@ -322,11 +423,20 @@ export const atoms = {
pl_xl: {
paddingLeft: tokens.space.xl,
},
pl_xxl: {
paddingLeft: tokens.space.xxl,
pl_2xl: {
paddingLeft: tokens.space._2xl,
},
pr_xxs: {
paddingRight: tokens.space.xxs,
pl_3xl: {
paddingLeft: tokens.space._3xl,
},
pl_4xl: {
paddingLeft: tokens.space._4xl,
},
pl_5xl: {
paddingLeft: tokens.space._5xl,
},
pr_2xs: {
paddingRight: tokens.space._2xs,
},
pr_xs: {
paddingRight: tokens.space.xs,
@@ -343,15 +453,24 @@ export const atoms = {
pr_xl: {
paddingRight: tokens.space.xl,
},
pr_xxl: {
paddingRight: tokens.space.xxl,
pr_2xl: {
paddingRight: tokens.space._2xl,
},
pr_3xl: {
paddingRight: tokens.space._3xl,
},
pr_4xl: {
paddingRight: tokens.space._4xl,
},
pr_5xl: {
paddingRight: tokens.space._5xl,
},
/*
* Margin
*/
m_xxs: {
margin: tokens.space.xxs,
m_2xs: {
margin: tokens.space._2xs,
},
m_xs: {
margin: tokens.space.xs,
@@ -368,12 +487,21 @@ export const atoms = {
m_xl: {
margin: tokens.space.xl,
},
m_xxl: {
margin: tokens.space.xxl,
m_2xl: {
margin: tokens.space._2xl,
},
mx_xxs: {
marginLeft: tokens.space.xxs,
marginRight: tokens.space.xxs,
m_3xl: {
margin: tokens.space._3xl,
},
m_4xl: {
margin: tokens.space._4xl,
},
m_5xl: {
margin: tokens.space._5xl,
},
mx_2xs: {
marginLeft: tokens.space._2xs,
marginRight: tokens.space._2xs,
},
mx_xs: {
marginLeft: tokens.space.xs,
@@ -395,13 +523,25 @@ export const atoms = {
marginLeft: tokens.space.xl,
marginRight: tokens.space.xl,
},
mx_xxl: {
marginLeft: tokens.space.xxl,
marginRight: tokens.space.xxl,
mx_2xl: {
marginLeft: tokens.space._2xl,
marginRight: tokens.space._2xl,
},
my_xxs: {
marginTop: tokens.space.xxs,
marginBottom: tokens.space.xxs,
mx_3xl: {
marginLeft: tokens.space._3xl,
marginRight: tokens.space._3xl,
},
mx_4xl: {
marginLeft: tokens.space._4xl,
marginRight: tokens.space._4xl,
},
mx_5xl: {
marginLeft: tokens.space._5xl,
marginRight: tokens.space._5xl,
},
my_2xs: {
marginTop: tokens.space._2xs,
marginBottom: tokens.space._2xs,
},
my_xs: {
marginTop: tokens.space.xs,
@@ -423,12 +563,24 @@ export const atoms = {
marginTop: tokens.space.xl,
marginBottom: tokens.space.xl,
},
my_xxl: {
marginTop: tokens.space.xxl,
marginBottom: tokens.space.xxl,
my_2xl: {
marginTop: tokens.space._2xl,
marginBottom: tokens.space._2xl,
},
mt_xxs: {
marginTop: tokens.space.xxs,
my_3xl: {
marginTop: tokens.space._3xl,
marginBottom: tokens.space._3xl,
},
my_4xl: {
marginTop: tokens.space._4xl,
marginBottom: tokens.space._4xl,
},
my_5xl: {
marginTop: tokens.space._5xl,
marginBottom: tokens.space._5xl,
},
mt_2xs: {
marginTop: tokens.space._2xs,
},
mt_xs: {
marginTop: tokens.space.xs,
@@ -445,11 +597,20 @@ export const atoms = {
mt_xl: {
marginTop: tokens.space.xl,
},
mt_xxl: {
marginTop: tokens.space.xxl,
mt_2xl: {
marginTop: tokens.space._2xl,
},
mb_xxs: {
marginBottom: tokens.space.xxs,
mt_3xl: {
marginTop: tokens.space._3xl,
},
mt_4xl: {
marginTop: tokens.space._4xl,
},
mt_5xl: {
marginTop: tokens.space._5xl,
},
mb_2xs: {
marginBottom: tokens.space._2xs,
},
mb_xs: {
marginBottom: tokens.space.xs,
@@ -466,11 +627,20 @@ export const atoms = {
mb_xl: {
marginBottom: tokens.space.xl,
},
mb_xxl: {
marginBottom: tokens.space.xxl,
mb_2xl: {
marginBottom: tokens.space._2xl,
},
ml_xxs: {
marginLeft: tokens.space.xxs,
mb_3xl: {
marginBottom: tokens.space._3xl,
},
mb_4xl: {
marginBottom: tokens.space._4xl,
},
mb_5xl: {
marginBottom: tokens.space._5xl,
},
ml_2xs: {
marginLeft: tokens.space._2xs,
},
ml_xs: {
marginLeft: tokens.space.xs,
@@ -487,11 +657,20 @@ export const atoms = {
ml_xl: {
marginLeft: tokens.space.xl,
},
ml_xxl: {
marginLeft: tokens.space.xxl,
ml_2xl: {
marginLeft: tokens.space._2xl,
},
mr_xxs: {
marginRight: tokens.space.xxs,
ml_3xl: {
marginLeft: tokens.space._3xl,
},
ml_4xl: {
marginLeft: tokens.space._4xl,
},
ml_5xl: {
marginLeft: tokens.space._5xl,
},
mr_2xs: {
marginRight: tokens.space._2xs,
},
mr_xs: {
marginRight: tokens.space.xs,
@@ -508,7 +687,16 @@ export const atoms = {
mr_xl: {
marginRight: tokens.space.xl,
},
mr_xxl: {
marginRight: tokens.space.xxl,
mr_2xl: {
marginRight: tokens.space._2xl,
},
mr_3xl: {
marginRight: tokens.space._3xl,
},
mr_4xl: {
marginRight: tokens.space._4xl,
},
mr_5xl: {
marginRight: tokens.space._5xl,
},
} as const
+1
View File
@@ -5,6 +5,7 @@ import * as themes from '#/alf/themes'
export * as tokens from '#/alf/tokens'
export {atoms} from '#/alf/atoms'
export * from '#/alf/util/platform'
export * from '#/alf/util/flatten'
type BreakpointName = keyof typeof breakpoints
+259 -47
View File
@@ -1,108 +1,320 @@
import * as tokens from '#/alf/tokens'
import type {Mutable} from '#/alf/types'
import {atoms} from '#/alf/atoms'
export type ThemeName = 'light' | 'dark'
export type ThemeName = 'light' | 'dim' | 'dark'
export type ReadonlyTheme = typeof light
export type Theme = Mutable<ReadonlyTheme>
export type ReadonlyPalette = typeof lightPalette
export type Palette = Mutable<ReadonlyPalette>
export type Palette = {
primary: string
positive: string
negative: string
}
export const lightPalette = {
white: tokens.color.gray_0,
black: tokens.color.gray_1000,
export const lightPalette: Palette = {
primary: tokens.color.blue_500,
positive: tokens.color.green_500,
negative: tokens.color.red_500,
contrast_25: tokens.color.gray_25,
contrast_50: tokens.color.gray_50,
contrast_100: tokens.color.gray_100,
contrast_200: tokens.color.gray_200,
contrast_300: tokens.color.gray_300,
contrast_400: tokens.color.gray_400,
contrast_500: tokens.color.gray_500,
contrast_600: tokens.color.gray_600,
contrast_700: tokens.color.gray_700,
contrast_800: tokens.color.gray_800,
contrast_900: tokens.color.gray_900,
contrast_950: tokens.color.gray_950,
contrast_975: tokens.color.gray_975,
primary_25: tokens.color.blue_25,
primary_50: tokens.color.blue_50,
primary_100: tokens.color.blue_100,
primary_200: tokens.color.blue_200,
primary_300: tokens.color.blue_300,
primary_400: tokens.color.blue_400,
primary_500: tokens.color.blue_500,
primary_600: tokens.color.blue_600,
primary_700: tokens.color.blue_700,
primary_800: tokens.color.blue_800,
primary_900: tokens.color.blue_900,
primary_950: tokens.color.blue_950,
primary_975: tokens.color.blue_975,
positive_25: tokens.color.green_25,
positive_50: tokens.color.green_50,
positive_100: tokens.color.green_100,
positive_200: tokens.color.green_200,
positive_300: tokens.color.green_300,
positive_400: tokens.color.green_400,
positive_500: tokens.color.green_500,
positive_600: tokens.color.green_600,
positive_700: tokens.color.green_700,
positive_800: tokens.color.green_800,
positive_900: tokens.color.green_900,
positive_950: tokens.color.green_950,
positive_975: tokens.color.green_975,
negative_25: tokens.color.red_25,
negative_50: tokens.color.red_50,
negative_100: tokens.color.red_100,
negative_200: tokens.color.red_200,
negative_300: tokens.color.red_300,
negative_400: tokens.color.red_400,
negative_500: tokens.color.red_500,
negative_600: tokens.color.red_600,
negative_700: tokens.color.red_700,
negative_800: tokens.color.red_800,
negative_900: tokens.color.red_900,
negative_950: tokens.color.red_950,
negative_975: tokens.color.red_975,
} as const
export const darkPalette: Palette = {
primary: tokens.color.blue_500,
positive: tokens.color.green_400,
negative: tokens.color.red_400,
white: tokens.color.gray_0,
black: tokens.color.gray_1000,
contrast_25: tokens.color.gray_975,
contrast_50: tokens.color.gray_950,
contrast_100: tokens.color.gray_900,
contrast_200: tokens.color.gray_800,
contrast_300: tokens.color.gray_700,
contrast_400: tokens.color.gray_600,
contrast_500: tokens.color.gray_500,
contrast_600: tokens.color.gray_400,
contrast_700: tokens.color.gray_300,
contrast_800: tokens.color.gray_200,
contrast_900: tokens.color.gray_100,
contrast_950: tokens.color.gray_50,
contrast_975: tokens.color.gray_25,
primary_25: tokens.color.blue_25,
primary_50: tokens.color.blue_50,
primary_100: tokens.color.blue_100,
primary_200: tokens.color.blue_200,
primary_300: tokens.color.blue_300,
primary_400: tokens.color.blue_400,
primary_500: tokens.color.blue_500,
primary_600: tokens.color.blue_600,
primary_700: tokens.color.blue_700,
primary_800: tokens.color.blue_800,
primary_900: tokens.color.blue_900,
primary_950: tokens.color.blue_950,
primary_975: tokens.color.blue_975,
positive_25: tokens.color.green_25,
positive_50: tokens.color.green_50,
positive_100: tokens.color.green_100,
positive_200: tokens.color.green_200,
positive_300: tokens.color.green_300,
positive_400: tokens.color.green_400,
positive_500: tokens.color.green_500,
positive_600: tokens.color.green_600,
positive_700: tokens.color.green_700,
positive_800: tokens.color.green_800,
positive_900: tokens.color.green_900,
positive_950: tokens.color.green_950,
positive_975: tokens.color.green_975,
negative_25: tokens.color.red_25,
negative_50: tokens.color.red_50,
negative_100: tokens.color.red_100,
negative_200: tokens.color.red_200,
negative_300: tokens.color.red_300,
negative_400: tokens.color.red_400,
negative_500: tokens.color.red_500,
negative_600: tokens.color.red_600,
negative_700: tokens.color.red_700,
negative_800: tokens.color.red_800,
negative_900: tokens.color.red_900,
negative_950: tokens.color.red_950,
negative_975: tokens.color.red_975,
} as const
export const light = {
name: 'light',
palette: lightPalette,
atoms: {
text: {
color: tokens.color.gray_1000,
color: lightPalette.black,
},
text_contrast_700: {
color: tokens.color.gray_700,
color: lightPalette.contrast_700,
},
text_contrast_600: {
color: lightPalette.contrast_600,
},
text_contrast_500: {
color: tokens.color.gray_500,
color: lightPalette.contrast_500,
},
text_contrast_400: {
color: lightPalette.contrast_400,
},
text_inverted: {
color: tokens.color.white,
color: lightPalette.white,
},
bg: {
backgroundColor: tokens.color.white,
backgroundColor: lightPalette.white,
},
bg_contrast_25: {
backgroundColor: lightPalette.contrast_25,
},
bg_contrast_50: {
backgroundColor: lightPalette.contrast_50,
},
bg_contrast_100: {
backgroundColor: tokens.color.gray_100,
backgroundColor: lightPalette.contrast_100,
},
bg_contrast_200: {
backgroundColor: tokens.color.gray_200,
backgroundColor: lightPalette.contrast_200,
},
bg_contrast_300: {
backgroundColor: tokens.color.gray_300,
},
bg_positive: {
backgroundColor: tokens.color.green_500,
},
bg_negative: {
backgroundColor: tokens.color.red_400,
backgroundColor: lightPalette.contrast_300,
},
border: {
borderColor: tokens.color.gray_200,
borderColor: lightPalette.contrast_100,
},
border_contrast_500: {
borderColor: tokens.color.gray_500,
border_contrast: {
borderColor: lightPalette.contrast_400,
},
shadow_sm: {
...atoms.shadow_sm,
shadowColor: lightPalette.black,
},
shadow_md: {
...atoms.shadow_md,
shadowColor: lightPalette.black,
},
shadow_lg: {
...atoms.shadow_lg,
shadowColor: lightPalette.black,
},
},
}
export const dim: Theme = {
name: 'dim',
palette: darkPalette,
atoms: {
text: {
color: darkPalette.white,
},
text_contrast_700: {
color: darkPalette.contrast_800,
},
text_contrast_600: {
color: darkPalette.contrast_700,
},
text_contrast_500: {
color: darkPalette.contrast_600,
},
text_contrast_400: {
color: darkPalette.contrast_500,
},
text_inverted: {
color: darkPalette.black,
},
bg: {
backgroundColor: darkPalette.contrast_50,
},
bg_contrast_25: {
backgroundColor: darkPalette.contrast_100,
},
bg_contrast_50: {
backgroundColor: darkPalette.contrast_200,
},
bg_contrast_100: {
backgroundColor: darkPalette.contrast_300,
},
bg_contrast_200: {
backgroundColor: darkPalette.contrast_400,
},
bg_contrast_300: {
backgroundColor: darkPalette.contrast_500,
},
border: {
borderColor: darkPalette.contrast_200,
},
border_contrast: {
borderColor: darkPalette.contrast_400,
},
shadow_sm: {
...atoms.shadow_sm,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
shadow_md: {
...atoms.shadow_md,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
shadow_lg: {
...atoms.shadow_lg,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
},
}
export const dark: Theme = {
name: 'dark',
palette: darkPalette,
atoms: {
text: {
color: tokens.color.white,
color: darkPalette.white,
},
text_contrast_700: {
color: tokens.color.gray_300,
color: darkPalette.contrast_700,
},
text_contrast_600: {
color: darkPalette.contrast_600,
},
text_contrast_500: {
color: tokens.color.gray_500,
color: darkPalette.contrast_500,
},
text_contrast_400: {
color: darkPalette.contrast_400,
},
text_inverted: {
color: tokens.color.gray_1000,
color: darkPalette.black,
},
bg: {
backgroundColor: tokens.color.gray_1000,
backgroundColor: darkPalette.black,
},
bg_contrast_25: {
backgroundColor: darkPalette.contrast_50,
},
bg_contrast_50: {
backgroundColor: darkPalette.contrast_100,
},
bg_contrast_100: {
backgroundColor: tokens.color.gray_900,
backgroundColor: darkPalette.contrast_200,
},
bg_contrast_200: {
backgroundColor: tokens.color.gray_800,
backgroundColor: darkPalette.contrast_300,
},
bg_contrast_300: {
backgroundColor: tokens.color.gray_700,
},
bg_positive: {
backgroundColor: tokens.color.green_400,
},
bg_negative: {
backgroundColor: tokens.color.red_400,
backgroundColor: darkPalette.contrast_400,
},
border: {
borderColor: tokens.color.gray_800,
borderColor: darkPalette.contrast_100,
},
border_contrast_500: {
borderColor: tokens.color.gray_500,
border_contrast: {
borderColor: darkPalette.contrast_300,
},
shadow_sm: {
...atoms.shadow_sm,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
shadow_md: {
...atoms.shadow_md,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
shadow_lg: {
...atoms.shadow_lg,
shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack,
},
},
}
+121 -53
View File
@@ -1,79 +1,95 @@
const BLUE_HUE = 211
const GRAYSCALE_SATURATION = 22
const RED_HUE = 346
const GREEN_HUE = 152
export const color = {
white: '#FFFFFF',
trueBlack: '#000000',
gray_0: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 100%)`,
gray_100: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 95%)`,
gray_200: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 85%)`,
gray_300: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 75%)`,
gray_400: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 65%)`,
gray_500: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 55%)`,
gray_600: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 45%)`,
gray_700: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 35%)`,
gray_800: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 25%)`,
gray_900: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 15%)`,
gray_1000: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 5%)`,
gray_0: `hsl(${BLUE_HUE}, 20%, 100%)`,
gray_25: `hsl(${BLUE_HUE}, 20%, 97%)`,
gray_50: `hsl(${BLUE_HUE}, 20%, 95%)`,
gray_100: `hsl(${BLUE_HUE}, 20%, 90%)`,
gray_200: `hsl(${BLUE_HUE}, 20%, 80%)`,
gray_300: `hsl(${BLUE_HUE}, 20%, 70%)`,
gray_400: `hsl(${BLUE_HUE}, 20%, 60%)`,
gray_500: `hsl(${BLUE_HUE}, 20%, 50%)`,
gray_600: `hsl(${BLUE_HUE}, 20%, 42%)`,
gray_700: `hsl(${BLUE_HUE}, 20%, 34%)`,
gray_800: `hsl(${BLUE_HUE}, 20%, 26%)`,
gray_900: `hsl(${BLUE_HUE}, 20%, 18%)`,
gray_950: `hsl(${BLUE_HUE}, 20%, 10%)`,
gray_975: `hsl(${BLUE_HUE}, 20%, 7%)`,
gray_1000: `hsl(${BLUE_HUE}, 20%, 4%)`,
blue_0: `hsl(${BLUE_HUE}, 99%, 100%)`,
blue_100: `hsl(${BLUE_HUE}, 99%, 93%)`,
blue_200: `hsl(${BLUE_HUE}, 99%, 83%)`,
blue_300: `hsl(${BLUE_HUE}, 99%, 73%)`,
blue_400: `hsl(${BLUE_HUE}, 99%, 63%)`,
blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
blue_100: `hsl(${BLUE_HUE}, 99%, 90%)`,
blue_200: `hsl(${BLUE_HUE}, 99%, 80%)`,
blue_300: `hsl(${BLUE_HUE}, 99%, 70%)`,
blue_400: `hsl(${BLUE_HUE}, 99%, 60%)`,
blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
blue_600: `hsl(${BLUE_HUE}, 99%, 43%)`,
blue_700: `hsl(${BLUE_HUE}, 99%, 33%)`,
blue_800: `hsl(${BLUE_HUE}, 99%, 23%)`,
blue_900: `hsl(${BLUE_HUE}, 99%, 13%)`,
blue_1000: `hsl(${BLUE_HUE}, 99%, 8%)`,
blue_600: `hsl(${BLUE_HUE}, 99%, 42%)`,
blue_700: `hsl(${BLUE_HUE}, 99%, 34%)`,
blue_800: `hsl(${BLUE_HUE}, 99%, 26%)`,
blue_900: `hsl(${BLUE_HUE}, 99%, 18%)`,
blue_950: `hsl(${BLUE_HUE}, 99%, 10%)`,
blue_975: `hsl(${BLUE_HUE}, 99%, 7%)`,
green_0: `hsl(130, 60%, 100%)`,
green_100: `hsl(130, 60%, 95%)`,
green_200: `hsl(130, 60%, 85%)`,
green_300: `hsl(130, 60%, 75%)`,
green_400: `hsl(130, 60%, 65%)`,
green_500: `hsl(130, 60%, 55%)`,
green_600: `hsl(130, 60%, 45%)`,
green_700: `hsl(130, 60%, 35%)`,
green_800: `hsl(130, 60%, 25%)`,
green_900: `hsl(130, 60%, 15%)`,
green_1000: `hsl(130, 60%, 5%)`,
green_25: `hsl(${GREEN_HUE}, 82%, 97%)`,
green_50: `hsl(${GREEN_HUE}, 82%, 95%)`,
green_100: `hsl(${GREEN_HUE}, 82%, 90%)`,
green_200: `hsl(${GREEN_HUE}, 82%, 80%)`,
green_300: `hsl(${GREEN_HUE}, 82%, 70%)`,
green_400: `hsl(${GREEN_HUE}, 82%, 60%)`,
green_500: `hsl(${GREEN_HUE}, 82%, 50%)`,
green_600: `hsl(${GREEN_HUE}, 82%, 42%)`,
green_700: `hsl(${GREEN_HUE}, 82%, 34%)`,
green_800: `hsl(${GREEN_HUE}, 82%, 26%)`,
green_900: `hsl(${GREEN_HUE}, 82%, 18%)`,
green_950: `hsl(${GREEN_HUE}, 82%, 10%)`,
green_975: `hsl(${GREEN_HUE}, 82%, 7%)`,
red_0: `hsl(349, 96%, 100%)`,
red_100: `hsl(349, 96%, 95%)`,
red_200: `hsl(349, 96%, 85%)`,
red_300: `hsl(349, 96%, 75%)`,
red_400: `hsl(349, 96%, 65%)`,
red_500: `hsl(349, 96%, 55%)`,
red_600: `hsl(349, 96%, 45%)`,
red_700: `hsl(349, 96%, 35%)`,
red_800: `hsl(349, 96%, 25%)`,
red_900: `hsl(349, 96%, 15%)`,
red_1000: `hsl(349, 96%, 5%)`,
red_25: `hsl(${RED_HUE}, 91%, 97%)`,
red_50: `hsl(${RED_HUE}, 91%, 95%)`,
red_100: `hsl(${RED_HUE}, 91%, 90%)`,
red_200: `hsl(${RED_HUE}, 91%, 80%)`,
red_300: `hsl(${RED_HUE}, 91%, 70%)`,
red_400: `hsl(${RED_HUE}, 91%, 60%)`,
red_500: `hsl(${RED_HUE}, 91%, 50%)`,
red_600: `hsl(${RED_HUE}, 91%, 42%)`,
red_700: `hsl(${RED_HUE}, 91%, 34%)`,
red_800: `hsl(${RED_HUE}, 91%, 26%)`,
red_900: `hsl(${RED_HUE}, 91%, 18%)`,
red_950: `hsl(${RED_HUE}, 91%, 10%)`,
red_975: `hsl(${RED_HUE}, 91%, 7%)`,
} as const
export const space = {
xxs: 2,
_2xs: 2,
xs: 4,
sm: 8,
md: 12,
lg: 18,
xl: 24,
xxl: 32,
lg: 16,
xl: 20,
_2xl: 24,
_3xl: 28,
_4xl: 32,
_5xl: 40,
} as const
export const fontSize = {
xxs: 10,
_2xs: 10,
xs: 12,
sm: 14,
md: 16,
lg: 18,
xl: 22,
xxl: 26,
xl: 20,
_2xl: 22,
_3xl: 26,
_4xl: 32,
_5xl: 40,
} as const
// TODO test
export const lineHeight = {
none: 1,
normal: 1.5,
@@ -81,6 +97,8 @@ export const lineHeight = {
} as const
export const borderRadius = {
_2xs: 2,
xs: 4,
sm: 8,
md: 12,
full: 999,
@@ -92,6 +110,56 @@ export const fontWeight = {
bold: '900',
} as const
export const gradients = {
sky: {
values: [
[0, '#0A7AFF'],
[1, '#59B9FF'],
],
hover_value: '#0A7AFF',
},
midnight: {
values: [
[0, '#022C5E'],
[1, '#4079BC'],
],
hover_value: '#022C5E',
},
sunrise: {
values: [
[0, '#4E90AE'],
[0.4, '#AEA3AB'],
[0.8, '#E6A98F'],
[1, '#F3A84C'],
],
hover_value: '#AEA3AB',
},
sunset: {
values: [
[0, '#6772AF'],
[0.6, '#B88BB6'],
[1, '#FFA6AC'],
],
hover_value: '#B88BB6',
},
nordic: {
values: [
[0, '#083367'],
[1, '#9EE8C1'],
],
hover_value: '#3A7085',
},
bonfire: {
values: [
[0, '#203E4E'],
[0.4, '#755B62'],
[0.8, '#CD7765'],
[1, '#EF956E'],
],
hover_value: '#755B62',
},
} as const
export type Color = keyof typeof color
export type Space = keyof typeof space
export type FontSize = keyof typeof fontSize
+3
View File
@@ -0,0 +1,3 @@
import {StyleSheet} from 'react-native'
export const flatten = StyleSheet.flatten
+507
View File
@@ -0,0 +1,507 @@
import React from 'react'
import {
Pressable,
Text,
PressableProps,
TextProps,
ViewStyle,
AccessibilityProps,
View,
TextStyle,
StyleSheet,
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {useTheme, atoms as a, tokens, web, native} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common'
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
export type ButtonColor =
| 'primary'
| 'secondary'
| 'negative'
| 'gradient_sky'
| 'gradient_midnight'
| 'gradient_sunrise'
| 'gradient_sunset'
| 'gradient_nordic'
| 'gradient_bonfire'
export type ButtonSize = 'small' | 'large'
export type VariantProps = {
/**
* The style variation of the button
*/
variant?: ButtonVariant
/**
* The color of the button
*/
color?: ButtonColor
/**
* The size of the button
*/
size?: ButtonSize
}
export type ButtonProps = React.PropsWithChildren<
Pick<PressableProps, 'disabled' | 'onPress'> &
AccessibilityProps &
VariantProps & {
label: string
}
>
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
const Context = React.createContext<
VariantProps & {
hovered: boolean
focused: boolean
pressed: boolean
disabled: boolean
}
>({
hovered: false,
focused: false,
pressed: false,
disabled: false,
})
export function useButtonContext() {
return React.useContext(Context)
}
export function Button({
children,
variant,
color,
size,
label,
disabled = false,
...rest
}: ButtonProps) {
const t = useTheme()
const [state, setState] = React.useState({
pressed: false,
hovered: false,
focused: false,
})
const onPressIn = React.useCallback(() => {
setState(s => ({
...s,
pressed: true,
}))
}, [setState])
const onPressOut = React.useCallback(() => {
setState(s => ({
...s,
pressed: false,
}))
}, [setState])
const onHoverIn = React.useCallback(() => {
setState(s => ({
...s,
hovered: true,
}))
}, [setState])
const onHoverOut = React.useCallback(() => {
setState(s => ({
...s,
hovered: false,
}))
}, [setState])
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
focused: true,
}))
}, [setState])
const onBlur = React.useCallback(() => {
setState(s => ({
...s,
focused: false,
}))
}, [setState])
const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = []
const hoverStyles: ViewStyle[] = []
const light = t.name === 'light'
if (color === 'primary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.primary_500,
})
hoverStyles.push({
backgroundColor: t.palette.primary_600,
})
} else {
baseStyles.push({
backgroundColor: t.palette.primary_700,
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: tokens.color.blue_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
? t.palette.primary_50
: t.palette.primary_950,
})
} else {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? t.palette.primary_100
: t.palette.primary_900,
})
}
}
} else if (color === 'secondary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: light
? tokens.color.gray_100
: tokens.color.gray_900,
})
hoverStyles.push({
backgroundColor: light
? tokens.color.gray_200
: tokens.color.gray_950,
})
} else {
baseStyles.push({
backgroundColor: light
? tokens.color.gray_300
: tokens.color.gray_950,
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500,
})
hoverStyles.push(a.border, t.atoms.bg_contrast_50)
} else {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? tokens.color.gray_100
: tokens.color.gray_900,
})
}
}
} else if (color === 'negative') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.negative_400,
})
hoverStyles.push({
backgroundColor: t.palette.negative_500,
})
} else {
baseStyles.push({
backgroundColor: t.palette.negative_600,
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.negative_400,
})
hoverStyles.push(a.border, {
backgroundColor: light
? t.palette.negative_50
: t.palette.negative_975,
})
} else {
baseStyles.push(a.border, {
borderColor: light
? t.palette.negative_200
: t.palette.negative_900,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? t.palette.negative_100
: t.palette.negative_950,
})
}
}
}
if (size === 'large') {
baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_sm)
} else if (size === 'small') {
baseStyles.push({paddingVertical: 9}, a.px_md, a.rounded_sm, a.gap_sm)
}
return {
baseStyles,
hoverStyles,
focusStyles: [
...hoverStyles,
{
outline: 0,
} as ViewStyle,
],
}
}, [t, variant, color, size, disabled])
const {gradientColors, gradientHoverColors, gradientLocations} =
React.useMemo(() => {
const colors: string[] = []
const hoverColors: string[] = []
const locations: number[] = []
const gradient = {
primary: tokens.gradients.sky,
secondary: tokens.gradients.sky,
negative: tokens.gradients.sky,
gradient_sky: tokens.gradients.sky,
gradient_midnight: tokens.gradients.midnight,
gradient_sunrise: tokens.gradients.sunrise,
gradient_sunset: tokens.gradients.sunset,
gradient_nordic: tokens.gradients.nordic,
gradient_bonfire: tokens.gradients.bonfire,
}[color || 'primary']
if (variant === 'gradient') {
colors.push(...gradient.values.map(([_, color]) => color))
hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
locations.push(...gradient.values.map(([location, _]) => location))
}
return {
gradientColors: colors,
gradientHoverColors: hoverColors,
gradientLocations: locations,
}
}, [variant, color])
const context = React.useMemo(
() => ({
...state,
variant,
color,
size,
disabled: disabled || false,
}),
[state, variant, color, size, disabled],
)
return (
<Pressable
role="button"
accessibilityHint={undefined} // optional
{...rest}
aria-label={label}
aria-pressed={state.pressed}
accessibilityLabel={label}
disabled={disabled || false}
accessibilityState={{
disabled: disabled || false,
}}
style={[
a.flex_row,
a.align_center,
a.overflow_hidden,
...baseStyles,
...(state.hovered || state.pressed ? hoverStyles : []),
...(state.focused ? focusStyles : []),
]}
onPressIn={onPressIn}
onPressOut={onPressOut}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
onFocus={onFocus}
onBlur={onBlur}>
{variant === 'gradient' && (
<LinearGradient
colors={
state.hovered || state.pressed || state.focused
? gradientHoverColors
: gradientColors
}
locations={gradientLocations}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[a.absolute, a.inset_0]}
/>
)}
<Context.Provider value={context}>
{typeof children === 'string' ? (
<ButtonText>{children}</ButtonText>
) : (
children
)}
</Context.Provider>
</Pressable>
)
}
export function useSharedButtonTextStyles() {
const t = useTheme()
const {color, variant, disabled, size} = useButtonContext()
return React.useMemo(() => {
const baseStyles: TextStyle[] = []
const light = t.name === 'light'
if (color === 'primary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({color: t.palette.white})
} else {
baseStyles.push({color: t.palette.white, opacity: 0.5})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({
color: light ? t.palette.primary_600 : t.palette.primary_500,
})
} else {
baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({color: t.palette.primary_600})
} else {
baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
}
}
} else if (color === 'secondary') {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_700 : tokens.color.gray_100,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_700,
})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_600 : tokens.color.gray_300,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_700,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_600 : tokens.color.gray_300,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_600,
})
}
}
} else if (color === 'negative') {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
baseStyles.push({color: t.palette.white})
} else {
baseStyles.push({color: t.palette.white, opacity: 0.5})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({color: t.palette.negative_400})
} else {
baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({color: t.palette.negative_400})
} else {
baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
}
}
} else {
if (!disabled) {
baseStyles.push({color: t.palette.white})
} else {
baseStyles.push({color: t.palette.white, opacity: 0.5})
}
}
if (size === 'large') {
baseStyles.push(
a.text_md,
web({paddingBottom: 1}),
native({marginTop: 2}),
)
} else {
baseStyles.push(
a.text_md,
web({paddingBottom: 1}),
native({marginTop: 2}),
)
}
return StyleSheet.flatten(baseStyles)
}, [t, variant, color, size, disabled])
}
export function ButtonText({children, style, ...rest}: ButtonTextProps) {
const textStyles = useSharedButtonTextStyles()
return (
<Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
{children}
</Text>
)
}
export function ButtonIcon({
icon: Comp,
}: {
icon: React.ComponentType<SVGIconProps>
}) {
const {size} = useButtonContext()
const textStyles = useSharedButtonTextStyles()
return (
<View style={[a.z_20]}>
<Comp
size={size === 'large' ? 'md' : 'sm'}
style={[{color: textStyles.color, pointerEvents: 'none'}]}
/>
</View>
)
}
+35
View File
@@ -0,0 +1,35 @@
import React from 'react'
import {useDialogStateContext} from '#/state/dialogs'
import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types'
export const Context = React.createContext<DialogContextProps>({
close: () => {},
})
export function useDialogContext() {
return React.useContext(Context)
}
export function useDialogControl() {
const id = React.useId()
const control = React.useRef<DialogControlProps>({
open: () => {},
close: () => {},
})
const {activeDialogs} = useDialogStateContext()
React.useEffect(() => {
activeDialogs.current.set(id, control)
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
activeDialogs.current.delete(id)
}
}, [id, activeDialogs])
return {
ref: control,
open: () => control.current.open(),
close: () => control.current.close(),
}
}
+162
View File
@@ -0,0 +1,162 @@
import React, {useImperativeHandle} from 'react'
import {View, Dimensions} from 'react-native'
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetTextInput,
BottomSheetView,
} from '@gorhom/bottom-sheet'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useTheme, atoms as a} from '#/alf'
import {Portal} from '#/components/Portal'
import {createInput} from '#/components/forms/TextField'
import {
DialogOuterProps,
DialogControlProps,
DialogInnerProps,
} from '#/components/Dialog/types'
import {Context} from '#/components/Dialog/context'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
// @ts-ignore
export const Input = createInput(BottomSheetTextInput)
export function Outer({
children,
control,
onClose,
nativeOptions,
}: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme()
const sheet = React.useRef<BottomSheet>(null)
const sheetOptions = nativeOptions?.sheet || {}
const hasSnapPoints = !!sheetOptions.snapPoints
const open = React.useCallback<DialogControlProps['open']>((i = 0) => {
sheet.current?.snapToIndex(i)
}, [])
const close = React.useCallback(() => {
sheet.current?.close()
onClose?.()
}, [onClose])
useImperativeHandle(
control.ref,
() => ({
open,
close,
}),
[open, close],
)
const context = React.useMemo(() => ({close}), [close])
return (
<Portal>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
{...sheetOptions}
ref={sheet}
index={-1}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={props => (
<BottomSheetBackdrop
opacity={0.4}
appearsOnIndex={0}
disappearsOnIndex={-1}
{...props}
/>
)}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onClose={onClose}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
</Portal>
)
}
// TODO a11y props here, or is that handled by the sheet?
export function Inner(props: DialogInnerProps) {
const insets = useSafeAreaInsets()
return (
<BottomSheetView
style={[
a.p_lg,
a.pt_3xl,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
},
]}>
{props.children}
</BottomSheetView>
)
}
export function ScrollableInner(props: DialogInnerProps) {
const insets = useSafeAreaInsets()
return (
<BottomSheetScrollView
style={[
a.flex_1, // main diff is this
a.p_lg,
a.pt_3xl,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
]}>
{props.children}
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
</BottomSheetScrollView>
)
}
export function Handle() {
const t = useTheme()
return (
<View
style={[
a.absolute,
a.rounded_sm,
a.z_10,
{
top: a.pt_lg.paddingTop,
width: 35,
height: 4,
alignSelf: 'center',
backgroundColor: t.palette.contrast_900,
opacity: 0.5,
},
]}
/>
)
}
export function Close() {
return null
}
+194
View File
@@ -0,0 +1,194 @@
import React, {useImperativeHandle} from 'react'
import {View, TouchableWithoutFeedback} from 'react-native'
import {FocusScope} from '@tamagui/focus-scope'
import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useTheme, atoms as a, useBreakpoints, web} from '#/alf'
import {Portal} from '#/components/Portal'
import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
import {Context} from '#/components/Dialog/context'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
export {Input} from '#/components/forms/TextField'
const stopPropagation = (e: any) => e.stopPropagation()
export function Outer({
control,
onClose,
children,
}: React.PropsWithChildren<DialogOuterProps>) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const [isOpen, setIsOpen] = React.useState(false)
const [isVisible, setIsVisible] = React.useState(true)
const open = React.useCallback(() => {
setIsOpen(true)
}, [setIsOpen])
const close = React.useCallback(async () => {
setIsVisible(false)
await new Promise(resolve => setTimeout(resolve, 150))
setIsOpen(false)
setIsVisible(true)
onClose?.()
}, [onClose, setIsOpen])
useImperativeHandle(
control.ref,
() => ({
open,
close,
}),
[open, close],
)
React.useEffect(() => {
if (!isOpen) return
function handler(e: KeyboardEvent) {
if (e.key === 'Escape') close()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [isOpen, close])
const context = React.useMemo(
() => ({
close,
}),
[close],
)
return (
<>
{isOpen && (
<Portal>
<Context.Provider value={context}>
<TouchableWithoutFeedback
accessibilityHint={undefined}
accessibilityLabel={_(msg`Close active dialog`)}
onPress={close}>
<View
style={[
web(a.fixed),
a.inset_0,
a.z_10,
a.align_center,
gtMobile ? a.p_lg : a.p_md,
{overflowY: 'auto'},
]}>
{isVisible && (
<Animated.View
entering={FadeIn.duration(150)}
// exiting={FadeOut.duration(150)}
style={[
web(a.fixed),
a.inset_0,
{opacity: 0.5, backgroundColor: t.palette.black},
]}
/>
)}
<View
style={[
a.w_full,
a.z_20,
a.justify_center,
a.align_center,
{
minHeight: web('calc(90vh - 36px)') || undefined,
},
]}>
{isVisible ? children : null}
</View>
</View>
</TouchableWithoutFeedback>
</Context.Provider>
</Portal>
)}
</>
)
}
export function Inner({
children,
style,
label,
accessibilityLabelledBy,
accessibilityDescribedBy,
}: DialogInnerProps) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<FocusScope loop enabled trapped>
<Animated.View
role="dialog"
aria-role="dialog"
aria-label={label}
aria-labelledby={accessibilityLabelledBy}
aria-describedby={accessibilityDescribedBy}
// @ts-ignore web only -prf
onClick={stopPropagation}
onStartShouldSetResponder={_ => true}
onTouchEnd={stopPropagation}
entering={FadeInDown.duration(100)}
// exiting={FadeOut.duration(100)}
style={[
a.relative,
a.rounded_md,
a.w_full,
a.border,
gtMobile ? a.p_xl : a.p_lg,
t.atoms.bg,
{
maxWidth: 600,
borderColor: t.palette.contrast_200,
shadowColor: t.palette.black,
shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
shadowRadius: 30,
},
...(Array.isArray(style) ? style : [style || {}]),
]}>
{children}
</Animated.View>
</FocusScope>
)
}
export const ScrollableInner = Inner
export function Handle() {
return null
}
/**
* TODO(eric) unused rn
*/
// export function Close() {
// const {_} = useLingui()
// const t = useTheme()
// const {close} = useDialogContext()
// return (
// <View
// style={[
// a.absolute,
// a.z_10,
// {
// top: a.pt_lg.paddingTop,
// right: a.pr_lg.paddingRight,
// },
// ]}>
// <Button onPress={close} label={_(msg`Close active dialog`)}>
// </Button>
// </View>
// )
// }
+43
View File
@@ -0,0 +1,43 @@
import React from 'react'
import type {ViewStyle, AccessibilityProps} from 'react-native'
import {BottomSheetProps} from '@gorhom/bottom-sheet'
type A11yProps = Required<AccessibilityProps>
export type DialogContextProps = {
close: () => void
}
export type DialogControlProps = {
open: (index?: number) => void
close: () => void
}
export type DialogOuterProps = {
control: {
ref: React.RefObject<DialogControlProps>
open: (index?: number) => void
close: () => void
}
onClose?: () => void
nativeOptions?: {
sheet?: Omit<BottomSheetProps, 'children'>
}
webOptions?: {}
}
type DialogInnerPropsBase<T> = React.PropsWithChildren<{
style?: ViewStyle
}> &
T
export type DialogInnerProps =
| DialogInnerPropsBase<{
label?: undefined
accessibilityLabelledBy: A11yProps['aria-labelledby']
accessibilityDescribedBy: string
}>
| DialogInnerPropsBase<{
label: string
accessibilityLabelledBy?: undefined
accessibilityDescribedBy?: undefined
}>
+191
View File
@@ -0,0 +1,191 @@
import React from 'react'
import {
Text,
TextStyle,
StyleProp,
GestureResponderEvent,
Linking,
} from 'react-native'
import {
useLinkProps,
useNavigation,
StackActions,
} from '@react-navigation/native'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {isWeb} from '#/platform/detection'
import {useTheme, web, flatten} from '#/alf'
import {Button, ButtonProps, useButtonContext} from '#/components/Button'
import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {
convertBskyAppUrlIfNeeded,
isExternalUrl,
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
import {useModalControls} from '#/state/modals'
import {router} from '#/routes'
export type LinkProps = Omit<
ButtonProps,
'style' | 'onPress' | 'disabled' | 'label'
> & {
/**
* `TextStyle` to apply to the anchor element itself. Does not apply to any children.
*/
style?: StyleProp<TextStyle>
/**
* The React Navigation `StackAction` to perform when the link is pressed.
*/
action?: 'push' | 'replace' | 'navigate'
/**
* If true, will warn the user if the link text does not match the href. Only
* works for Links with children that are strings i.e. text links.
*/
warnOnMismatchingTextChild?: boolean
label?: ButtonProps['label']
} & Pick<Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to'>
/**
* A interactive element that renders as a `<a>` tag on the web. On mobile it
* will translate the `href` to navigator screens and params and dispatch a
* navigation action.
*
* Intended to behave as a web anchor tag. For more complex routing, use a
* `Button`.
*/
export function Link({
children,
to,
action = 'push',
warnOnMismatchingTextChild,
style,
...rest
}: LinkProps) {
const navigation = useNavigation<NavigationProp>()
const {href} = useLinkProps<AllNavigatorParams>({
to:
typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to,
})
const isExternal = isExternalUrl(href)
const {openModal, closeModal} = useModalControls()
const onPress = React.useCallback(
(e: GestureResponderEvent) => {
const stringChildren = typeof children === 'string' ? children : ''
const requiresWarning = Boolean(
warnOnMismatchingTextChild &&
stringChildren &&
isExternal &&
linkRequiresWarning(href, stringChildren),
)
if (requiresWarning) {
e.preventDefault()
openModal({
name: 'link-warning',
text: stringChildren,
href: href,
})
} else {
e.preventDefault()
if (isExternal) {
Linking.openURL(href)
} else {
/**
* A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
* of @ts-ignore below.
*/
const event = e as any
const isMiddleClick = isWeb && event.button === 1
const isMetaKey =
isWeb &&
(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
const shouldOpenInNewTab = isMetaKey || isMiddleClick
if (
shouldOpenInNewTab ||
href.startsWith('http') ||
href.startsWith('mailto')
) {
Linking.openURL(href)
} else {
closeModal() // close any active modals
if (action === 'push') {
navigation.dispatch(StackActions.push(...router.matchPath(href)))
} else if (action === 'replace') {
navigation.dispatch(
StackActions.replace(...router.matchPath(href)),
)
} else if (action === 'navigate') {
// @ts-ignore
navigation.navigate(...router.matchPath(href))
} else {
throw Error('Unsupported navigator action.')
}
}
}
}
},
[
href,
isExternal,
warnOnMismatchingTextChild,
navigation,
action,
children,
closeModal,
openModal,
],
)
return (
<Button
label={href}
{...rest}
role="link"
accessibilityRole="link"
href={href}
onPress={onPress}
{...web({
hrefAttrs: {
target: isExternal ? 'blank' : undefined,
rel: isExternal ? 'noopener noreferrer' : undefined,
},
dataSet: {
// default to no underline, apply this ourselves
noUnderline: '1',
},
})}>
{typeof children === 'string' ? (
<LinkText style={style}>{children}</LinkText>
) : (
children
)}
</Button>
)
}
function LinkText({
children,
style,
}: React.PropsWithChildren<{
style?: StyleProp<TextStyle>
}>) {
const t = useTheme()
const {hovered} = useButtonContext()
return (
<Text
style={[
{color: t.palette.primary_500},
hovered && {
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
},
flatten(style),
]}>
{children as string}
</Text>
)
}
+56
View File
@@ -0,0 +1,56 @@
import React from 'react'
type Component = React.ReactElement
type ContextType = {
outlet: Component | null
append(id: string, component: Component): void
remove(id: string): void
}
type ComponentMap = {
[id: string]: Component
}
export const Context = React.createContext<ContextType>({
outlet: null,
append: () => {},
remove: () => {},
})
export function Provider(props: React.PropsWithChildren<{}>) {
const map = React.useRef<ComponentMap>({})
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
const append = React.useCallback<ContextType['append']>((id, component) => {
if (map.current[id]) return
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const remove = React.useCallback<ContextType['remove']>(id => {
delete map.current[id]
setOutlet(<>{Object.values(map.current)}</>)
}, [])
return (
<Context.Provider value={{outlet, append, remove}}>
{props.children}
</Context.Provider>
)
}
export function Outlet() {
const ctx = React.useContext(Context)
return ctx.outlet
}
export function Portal({children}: React.PropsWithChildren<{}>) {
const {append, remove} = React.useContext(Context)
const id = React.useId()
React.useEffect(() => {
append(id, children as Component)
return () => remove(id)
}, [id, children, append, remove])
return null
}
+119
View File
@@ -0,0 +1,119 @@
import React from 'react'
import {View, PressableProps} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useTheme, atoms as a} from '#/alf'
import {H4, P} from '#/components/Typography'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
export {useDialogControl as usePromptControl} from '#/components/Dialog'
const Context = React.createContext<{
titleId: string
descriptionId: string
}>({
titleId: '',
descriptionId: '',
})
export function Outer({
children,
control,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
}>) {
const titleId = React.useId()
const descriptionId = React.useId()
const context = React.useMemo(
() => ({titleId, descriptionId}),
[titleId, descriptionId],
)
return (
<Dialog.Outer control={control}>
<Context.Provider value={context}>
<Dialog.Handle />
<Dialog.Inner
accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId}
style={{width: 'auto', maxWidth: 400}}>
{children}
</Dialog.Inner>
</Context.Provider>
</Dialog.Outer>
)
}
export function Title({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const {titleId} = React.useContext(Context)
return (
<H4
nativeID={titleId}
style={[a.font_bold, t.atoms.text_contrast_700, a.pb_sm]}>
{children}
</H4>
)
}
export function Description({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const {descriptionId} = React.useContext(Context)
return (
<P nativeID={descriptionId} style={[t.atoms.text, a.pb_lg]}>
{children}
</P>
)
}
export function Actions({children}: React.PropsWithChildren<{}>) {
return (
<View style={[a.w_full, a.flex_row, a.gap_sm, a.justify_end]}>
{children}
</View>
)
}
export function Cancel({
children,
}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) {
const {_} = useLingui()
const {close} = Dialog.useDialogContext()
return (
<Button
variant="solid"
color="secondary"
size="small"
label={_(msg`Cancel`)}
onPress={close}>
{children}
</Button>
)
}
export function Action({
children,
onPress,
}: React.PropsWithChildren<{onPress?: () => void}>) {
const {_} = useLingui()
const {close} = Dialog.useDialogContext()
const handleOnPress = React.useCallback(() => {
close()
onPress?.()
}, [close, onPress])
return (
<Button
variant="solid"
color="primary"
size="small"
label={_(msg`Confirm`)}
onPress={handleOnPress}>
{children}
</Button>
)
}
@@ -1,6 +1,7 @@
import React from 'react'
import {Text as RNText, TextProps} from 'react-native'
import {useTheme, atoms, web} from '#/alf'
import {useTheme, atoms, web, flatten} from '#/alf'
export function Text({style, ...rest}: TextProps) {
const t = useTheme()
@@ -18,7 +19,7 @@ export function H1({style, ...rest}: TextProps) {
<RNText
{...attr}
{...rest}
style={[atoms.text_xl, atoms.font_bold, t.atoms.text, style]}
style={[atoms.text_5xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
@@ -34,7 +35,7 @@ export function H2({style, ...rest}: TextProps) {
<RNText
{...attr}
{...rest}
style={[atoms.text_lg, atoms.font_bold, t.atoms.text, style]}
style={[atoms.text_4xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
@@ -50,7 +51,7 @@ export function H3({style, ...rest}: TextProps) {
<RNText
{...attr}
{...rest}
style={[atoms.text_md, atoms.font_bold, t.atoms.text, style]}
style={[atoms.text_3xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
@@ -66,7 +67,7 @@ export function H4({style, ...rest}: TextProps) {
<RNText
{...attr}
{...rest}
style={[atoms.text_sm, atoms.font_bold, t.atoms.text, style]}
style={[atoms.text_2xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
@@ -82,7 +83,7 @@ export function H5({style, ...rest}: TextProps) {
<RNText
{...attr}
{...rest}
style={[atoms.text_xs, atoms.font_bold, t.atoms.text, style]}
style={[atoms.text_xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
@@ -98,7 +99,26 @@ export function H6({style, ...rest}: TextProps) {
<RNText
{...attr}
{...rest}
style={[atoms.text_xxs, atoms.font_bold, t.atoms.text, style]}
style={[atoms.text_lg, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
export function P({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'paragraph',
}) || {}
const _style = flatten(style)
const lineHeight =
(_style?.lineHeight || atoms.text_md.lineHeight) *
atoms.leading_normal.lineHeight
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_md, t.atoms.text, _style, {lineHeight}]}
/>
)
}
@@ -0,0 +1,108 @@
import React from 'react'
import {View, Pressable} from 'react-native'
import DateTimePicker, {
BaseProps as DateTimePickerProps,
} from '@react-native-community/datetimepicker'
import {useTheme, atoms} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import * as TextField from '#/components/forms/TextField'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
import {DateFieldProps} from '#/components/forms/DateField/types'
import {
localizeDate,
toSimpleDateString,
} from '#/components/forms/DateField/utils'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
export function DateField({
value,
onChangeDate,
label,
isInvalid,
testID,
}: DateFieldProps) {
const t = useTheme()
const [open, setOpen] = React.useState(false)
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {chromeFocus, chromeError, chromeErrorHover} =
TextField.useSharedInputStyles()
const onChangeInternal = React.useCallback<
Required<DateTimePickerProps>['onChange']
>(
(_event, date) => {
setOpen(false)
if (date) {
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
}
},
[onChangeDate, setOpen],
)
return (
<View style={[atoms.relative, atoms.w_full]}>
<Pressable
aria-label={label}
accessibilityLabel={label}
accessibilityHint={undefined}
onPress={() => setOpen(true)}
onPressIn={onPressIn}
onPressOut={onPressOut}
onFocus={onFocus}
onBlur={onBlur}
style={[
{
paddingTop: 16,
paddingBottom: 16,
borderColor: 'transparent',
borderWidth: 2,
},
atoms.flex_row,
atoms.flex_1,
atoms.w_full,
atoms.px_lg,
atoms.rounded_sm,
t.atoms.bg_contrast_50,
focused || pressed ? chromeFocus : {},
isInvalid ? chromeError : {},
isInvalid && (focused || pressed) ? chromeErrorHover : {},
]}>
<TextField.Icon icon={CalendarDays} />
<Text
style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}>
{localizeDate(value)}
</Text>
</Pressable>
{open && (
<DateTimePicker
aria-label={label}
accessibilityLabel={label}
accessibilityHint={undefined}
testID={`${testID}-datepicker`}
mode="date"
timeZoneName={'Etc/UTC'}
display="spinner"
// @ts-ignore applies in iOS only -prf
themeVariant={t.name === 'dark' ? 'dark' : 'light'}
value={new Date(value)}
onChange={onChangeInternal}
/>
)}
</View>
)
}
+56
View File
@@ -0,0 +1,56 @@
import React from 'react'
import {View} from 'react-native'
import DateTimePicker, {
DateTimePickerEvent,
} from '@react-native-community/datetimepicker'
import {useTheme, atoms} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {toSimpleDateString} from '#/components/forms/DateField/utils'
import {DateFieldProps} from '#/components/forms/DateField/types'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
/**
* Date-only input. Accepts a date in the format YYYY-MM-DD, and reports date
* changes in the same format.
*
* For dates of unknown format, convert with the
* `utils.toSimpleDateString(Date)` export of this file.
*/
export function DateField({
value,
onChangeDate,
testID,
label,
}: DateFieldProps) {
const t = useTheme()
const onChangeInternal = React.useCallback(
(event: DateTimePickerEvent, date: Date | undefined) => {
if (date) {
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
}
},
[onChangeDate],
)
return (
<View style={[atoms.relative, atoms.w_full]}>
<DateTimePicker
aria-label={label}
accessibilityLabel={label}
accessibilityHint={undefined}
testID={`${testID}-datepicker`}
mode="date"
timeZoneName={'Etc/UTC'}
display="spinner"
themeVariant={t.name === 'dark' ? 'dark' : 'light'}
value={new Date(value)}
onChange={onChangeInternal}
/>
</View>
)
}
@@ -0,0 +1,64 @@
import React from 'react'
import {TextInput, TextInputProps, StyleSheet} from 'react-native'
// @ts-ignore
import {unstable_createElement} from 'react-native-web'
import * as TextField from '#/components/forms/TextField'
import {toSimpleDateString} from '#/components/forms/DateField/utils'
import {DateFieldProps} from '#/components/forms/DateField/types'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
const InputBase = React.forwardRef<HTMLInputElement, TextInputProps>(
({style, ...props}, ref) => {
return unstable_createElement('input', {
...props,
ref,
type: 'date',
style: [
StyleSheet.flatten(style),
{
background: 'transparent',
border: 0,
},
],
})
},
)
InputBase.displayName = 'InputBase'
const Input = TextField.createInput(InputBase as unknown as typeof TextInput)
export function DateField({
value,
onChangeDate,
label,
isInvalid,
testID,
}: DateFieldProps) {
const handleOnChange = React.useCallback(
(e: any) => {
const date = e.target.valueAsDate || e.target.value
if (date) {
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
}
},
[onChangeDate],
)
return (
<TextField.Root isInvalid={isInvalid}>
<Input
value={value}
label={label}
onChange={handleOnChange}
onChangeText={() => {}}
testID={testID}
/>
</TextField.Root>
)
}
+7
View File
@@ -0,0 +1,7 @@
export type DateFieldProps = {
value: string
onChangeDate: (date: string) => void
label: string
isInvalid?: boolean
testID?: string
}
+16
View File
@@ -0,0 +1,16 @@
import {getLocales} from 'expo-localization'
const LOCALE = getLocales()[0]
// we need the date in the form yyyy-MM-dd to pass to the input
export function toSimpleDateString(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date
return _date.toISOString().split('T')[0]
}
export function localizeDate(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date
return new Intl.DateTimeFormat(LOCALE.languageTag, {
timeZone: 'UTC',
}).format(_date)
}
+43
View File
@@ -0,0 +1,43 @@
import React from 'react'
import {View} from 'react-native'
import {atoms, useTheme} from '#/alf'
/**
* NOT FINISHED, just here as a reference
*/
export function InputGroup(props: React.PropsWithChildren<{}>) {
const t = useTheme()
const children = React.Children.toArray(props.children)
const total = children.length
return (
<View style={[atoms.w_full]}>
{children.map((child, i) => {
return React.isValidElement(child) ? (
<React.Fragment key={i}>
{i > 0 ? (
<View
style={[atoms.border_b, {borderColor: t.palette.contrast_500}]}
/>
) : null}
{React.cloneElement(child, {
// @ts-ignore
style: [
...(Array.isArray(child.props?.style)
? child.props.style
: [child.props.style || {}]),
{
borderTopLeftRadius: i > 0 ? 0 : undefined,
borderTopRightRadius: i > 0 ? 0 : undefined,
borderBottomLeftRadius: i < total - 1 ? 0 : undefined,
borderBottomRightRadius: i < total - 1 ? 0 : undefined,
borderBottomWidth: i < total - 1 ? 0 : undefined,
},
],
})}
</React.Fragment>
) : null
})}
</View>
)
}
+334
View File
@@ -0,0 +1,334 @@
import React from 'react'
import {
View,
TextInput,
TextInputProps,
TextStyle,
ViewStyle,
Pressable,
StyleSheet,
AccessibilityProps,
} from 'react-native'
import {HITSLOP_20} from 'lib/constants'
import {isWeb} from '#/platform/detection'
import {useTheme, atoms as a, web, tokens, android} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Props as SVGIconProps} from '#/components/icons/common'
const Context = React.createContext<{
inputRef: React.RefObject<TextInput> | null
isInvalid: boolean
hovered: boolean
onHoverIn: () => void
onHoverOut: () => void
focused: boolean
onFocus: () => void
onBlur: () => void
}>({
inputRef: null,
isInvalid: false,
hovered: false,
onHoverIn: () => {},
onHoverOut: () => {},
focused: false,
onFocus: () => {},
onBlur: () => {},
})
export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
export function Root({children, isInvalid = false}: RootProps) {
const inputRef = React.useRef<TextInput>(null)
const rootRef = React.useRef<View>(null)
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const context = React.useMemo(
() => ({
inputRef,
hovered,
onHoverIn,
onHoverOut,
focused,
onFocus,
onBlur,
isInvalid,
}),
[
inputRef,
hovered,
onHoverIn,
onHoverOut,
focused,
onFocus,
onBlur,
isInvalid,
],
)
React.useLayoutEffect(() => {
const root = rootRef.current
if (!root || !isWeb) return
// @ts-ignore web only
root.tabIndex = -1
}, [])
return (
<Context.Provider value={context}>
<Pressable
accessibilityRole="button"
ref={rootRef}
role="none"
style={[
a.flex_row,
a.align_center,
a.relative,
a.w_full,
a.px_md,
{
paddingVertical: 14,
},
]}
// onPressIn/out don't work on android web
onPress={() => inputRef.current?.focus()}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}>
{children}
</Pressable>
</Context.Provider>
)
}
export function useSharedInputStyles() {
const t = useTheme()
return React.useMemo(() => {
const hover: ViewStyle[] = [
{
borderColor: t.palette.contrast_100,
},
]
const focus: ViewStyle[] = [
{
backgroundColor: t.palette.contrast_50,
borderColor: t.palette.primary_500,
},
]
const error: ViewStyle[] = [
{
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
},
]
const errorHover: ViewStyle[] = [
{
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: tokens.color.red_500,
},
]
return {
chromeHover: StyleSheet.flatten(hover),
chromeFocus: StyleSheet.flatten(focus),
chromeError: StyleSheet.flatten(error),
chromeErrorHover: StyleSheet.flatten(errorHover),
}
}, [t])
}
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
label: string
value: string
onChangeText: (value: string) => void
isInvalid?: boolean
}
export function createInput(Component: typeof TextInput) {
return function Input({
label,
placeholder,
value,
onChangeText,
isInvalid,
...rest
}: InputProps) {
const t = useTheme()
const ctx = React.useContext(Context)
const withinRoot = Boolean(ctx.inputRef)
const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
useSharedInputStyles()
if (!withinRoot) {
return (
<Root isInvalid={isInvalid}>
<Input
label={label}
placeholder={placeholder}
value={value}
onChangeText={onChangeText}
isInvalid={isInvalid}
{...rest}
/>
</Root>
)
}
return (
<>
<Component
accessibilityHint={undefined}
{...rest}
aria-label={label}
accessibilityLabel={label}
ref={ctx.inputRef}
value={value}
onChangeText={onChangeText}
onFocus={ctx.onFocus}
onBlur={ctx.onBlur}
placeholder={placeholder || label}
placeholderTextColor={t.palette.contrast_500}
hitSlop={HITSLOP_20}
style={[
a.relative,
a.z_20,
a.flex_1,
a.text_md,
t.atoms.text,
a.px_xs,
android({
paddingBottom: 2,
}),
{
lineHeight: a.text_md.lineHeight * 1.1875,
textAlignVertical: rest.multiline ? 'top' : undefined,
minHeight: rest.multiline ? 60 : undefined,
},
]}
/>
<View
style={[
a.z_10,
a.absolute,
a.inset_0,
a.rounded_sm,
t.atoms.bg_contrast_25,
{borderColor: 'transparent', borderWidth: 2},
ctx.hovered ? chromeHover : {},
ctx.focused ? chromeFocus : {},
ctx.isInvalid || isInvalid ? chromeError : {},
(ctx.isInvalid || isInvalid) && (ctx.hovered || ctx.focused)
? chromeErrorHover
: {},
]}
/>
</>
)
}
}
export const Input = createInput(TextInput)
export function Label({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
return (
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_600, a.mb_sm]}>
{children}
</Text>
)
}
export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
const t = useTheme()
const ctx = React.useContext(Context)
const {hover, focus, errorHover, errorFocus} = React.useMemo(() => {
const hover: TextStyle[] = [
{
color: t.palette.contrast_800,
},
]
const focus: TextStyle[] = [
{
color: t.palette.primary_500,
},
]
const errorHover: TextStyle[] = [
{
color: t.palette.negative_500,
},
]
const errorFocus: TextStyle[] = [
{
color: t.palette.negative_500,
},
]
return {
hover,
focus,
errorHover,
errorFocus,
}
}, [t])
return (
<View style={[a.z_20, a.pr_xs]}>
<Comp
size="md"
style={[
{color: t.palette.contrast_500, pointerEvents: 'none'},
ctx.hovered ? hover : {},
ctx.focused ? focus : {},
ctx.isInvalid && ctx.hovered ? errorHover : {},
ctx.isInvalid && ctx.focused ? errorFocus : {},
]}
/>
</View>
)
}
export function Suffix({
children,
label,
accessibilityHint,
}: React.PropsWithChildren<{
label: string
accessibilityHint?: AccessibilityProps['accessibilityHint']
}>) {
const t = useTheme()
const ctx = React.useContext(Context)
return (
<Text
aria-label={label}
accessibilityLabel={label}
accessibilityHint={accessibilityHint}
style={[
a.z_20,
a.pr_sm,
a.text_md,
t.atoms.text_contrast_400,
{
pointerEvents: 'none',
},
web({
marginTop: -2,
}),
ctx.hovered || ctx.focused
? {
color: t.palette.contrast_800,
}
: {},
]}>
{children}
</Text>
)
}
+473
View File
@@ -0,0 +1,473 @@
import React from 'react'
import {Pressable, View, ViewStyle} from 'react-native'
import {HITSLOP_10} from 'lib/constants'
import {useTheme, atoms as a, web, native} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
export type ItemState = {
name: string
selected: boolean
disabled: boolean
isInvalid: boolean
hovered: boolean
pressed: boolean
focused: boolean
}
const ItemContext = React.createContext<ItemState>({
name: '',
selected: false,
disabled: false,
isInvalid: false,
hovered: false,
pressed: false,
focused: false,
})
const GroupContext = React.createContext<{
values: string[]
disabled: boolean
type: 'radio' | 'checkbox'
maxSelectionsReached: boolean
setFieldValue: (props: {name: string; value: boolean}) => void
}>({
type: 'checkbox',
values: [],
disabled: false,
maxSelectionsReached: false,
setFieldValue: () => {},
})
export type GroupProps = React.PropsWithChildren<{
type?: 'radio' | 'checkbox'
values: string[]
maxSelections?: number
disabled?: boolean
onChange: (value: string[]) => void
label: string
}>
export type ItemProps = {
type?: 'radio' | 'checkbox'
name: string
label: string
value?: boolean
disabled?: boolean
onChange?: (selected: boolean) => void
isInvalid?: boolean
style?: (state: ItemState) => ViewStyle
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
}
export function useItemContext() {
return React.useContext(ItemContext)
}
export function Group({
children,
values: providedValues,
onChange,
disabled = false,
type = 'checkbox',
maxSelections,
label,
}: GroupProps) {
const groupRole = type === 'radio' ? 'radiogroup' : undefined
const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
const [maxReached, setMaxReached] = React.useState(false)
const setFieldValue = React.useCallback<
(props: {name: string; value: boolean}) => void
>(
({name, value}) => {
if (type === 'checkbox') {
const pruned = values.filter(v => v !== name)
const next = value ? pruned.concat(name) : pruned
onChange(next)
} else {
onChange([name])
}
},
[type, onChange, values],
)
React.useEffect(() => {
if (type === 'checkbox') {
if (
maxSelections &&
values.length >= maxSelections &&
maxReached === false
) {
setMaxReached(true)
} else if (
maxSelections &&
values.length < maxSelections &&
maxReached === true
) {
setMaxReached(false)
}
}
}, [type, values.length, maxSelections, maxReached, setMaxReached])
const context = React.useMemo(
() => ({
values,
type,
disabled,
maxSelectionsReached: maxReached,
setFieldValue,
}),
[values, disabled, type, maxReached, setFieldValue],
)
return (
<GroupContext.Provider value={context}>
<View
role={groupRole}
{...(groupRole === 'radiogroup'
? {
'aria-label': label,
accessibilityLabel: label,
accessibilityRole: groupRole,
}
: {})}>
{children}
</View>
</GroupContext.Provider>
)
}
export function Item({
children,
name,
value = false,
disabled: itemDisabled = false,
onChange,
isInvalid,
style,
type = 'checkbox',
label,
...rest
}: ItemProps) {
const {
values: selectedValues,
type: groupType,
disabled: groupDisabled,
setFieldValue,
maxSelectionsReached,
} = React.useContext(GroupContext)
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const role = groupType === 'radio' ? 'radio' : type
const selected = selectedValues.includes(name) || !!value
const disabled =
groupDisabled || itemDisabled || (!selected && maxSelectionsReached)
const onPress = React.useCallback(() => {
const next = !selected
setFieldValue({name, value: next})
onChange?.(next)
}, [name, selected, onChange, setFieldValue])
const state = React.useMemo(
() => ({
name,
selected,
disabled: disabled ?? false,
isInvalid: isInvalid ?? false,
hovered,
pressed,
focused,
}),
[name, selected, disabled, hovered, pressed, focused, isInvalid],
)
return (
<ItemContext.Provider value={state}>
<Pressable
accessibilityHint={undefined} // optional
hitSlop={HITSLOP_10}
{...rest}
disabled={disabled}
aria-disabled={disabled ?? false}
aria-checked={selected}
aria-invalid={isInvalid}
aria-label={label}
role={role}
accessibilityRole={role}
accessibilityState={{
disabled: disabled ?? false,
selected: selected,
}}
accessibilityLabel={label}
onPress={onPress}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
onPressIn={onPressIn}
onPressOut={onPressOut}
onFocus={onFocus}
onBlur={onBlur}
style={[
a.flex_row,
a.align_center,
a.gap_sm,
focused ? web({outline: 'none'}) : {},
style?.(state),
]}>
{typeof children === 'function' ? children(state) : children}
</Pressable>
</ItemContext.Provider>
)
}
export function Label({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const {disabled} = useItemContext()
return (
<Text
style={[
a.font_bold,
{
userSelect: 'none',
color: disabled ? t.palette.contrast_400 : t.palette.contrast_600,
},
native({
paddingTop: 3,
}),
]}>
{children}
</Text>
)
}
// TODO(eric) refactor to memoize styles without knowledge of state
export function createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
}: {
theme: ReturnType<typeof useTheme>
selected: boolean
hovered: boolean
focused: boolean
disabled: boolean
isInvalid: boolean
}) {
const base: ViewStyle[] = []
const baseHover: ViewStyle[] = []
const indicator: ViewStyle[] = []
if (selected) {
base.push({
backgroundColor:
t.name === 'light' ? t.palette.primary_25 : t.palette.primary_900,
borderColor: t.palette.primary_500,
})
if (hovered || focused) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
borderColor:
t.name === 'light' ? t.palette.primary_600 : t.palette.primary_400,
})
}
} else {
if (hovered || focused) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
borderColor: t.palette.contrast_500,
})
}
}
if (isInvalid) {
base.push({
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
})
if (hovered || focused) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: t.palette.negative_500,
})
}
}
if (disabled) {
base.push({
backgroundColor: t.palette.contrast_100,
borderColor: t.palette.contrast_400,
})
}
return {
baseStyles: base,
baseHoverStyles: disabled ? [] : baseHover,
indicatorStyles: indicator,
}
}
export function Checkbox() {
const t = useTheme()
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
const {baseStyles, baseHoverStyles, indicatorStyles} =
createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
})
return (
<View
style={[
a.justify_center,
a.align_center,
a.border,
a.rounded_xs,
t.atoms.border_contrast,
{
height: 20,
width: 20,
},
baseStyles,
hovered || focused ? baseHoverStyles : {},
]}>
{selected ? (
<View
style={[
a.absolute,
a.rounded_2xs,
{height: 12, width: 12},
selected
? {
backgroundColor: t.palette.primary_500,
}
: {},
indicatorStyles,
]}
/>
) : null}
</View>
)
}
export function Switch() {
const t = useTheme()
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
const {baseStyles, baseHoverStyles, indicatorStyles} =
createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
})
return (
<View
style={[
a.relative,
a.border,
a.rounded_full,
t.atoms.bg,
t.atoms.border_contrast,
{
height: 20,
width: 30,
},
baseStyles,
hovered || focused ? baseHoverStyles : {},
]}>
<View
style={[
a.absolute,
a.rounded_full,
{
height: 12,
width: 12,
top: 3,
left: 3,
backgroundColor: t.palette.contrast_400,
},
selected
? {
backgroundColor: t.palette.primary_500,
left: 13,
}
: {},
indicatorStyles,
]}
/>
</View>
)
}
export function Radio() {
const t = useTheme()
const {selected, hovered, focused, disabled, isInvalid} =
React.useContext(ItemContext)
const {baseStyles, baseHoverStyles, indicatorStyles} =
createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
})
return (
<View
style={[
a.justify_center,
a.align_center,
a.border,
a.rounded_full,
t.atoms.border_contrast,
{
height: 20,
width: 20,
},
baseStyles,
hovered || focused ? baseHoverStyles : {},
]}>
{selected ? (
<View
style={[
a.absolute,
a.rounded_full,
{height: 12, width: 12},
selected
? {
backgroundColor: t.palette.primary_500,
}
: {},
indicatorStyles,
]}
/>
) : null}
</View>
)
}
+124
View File
@@ -0,0 +1,124 @@
import React from 'react'
import {View, AccessibilityProps, TextStyle, ViewStyle} from 'react-native'
import {atoms as a, useTheme, native} from '#/alf'
import {Text} from '#/components/Typography'
import * as Toggle from '#/components/forms/Toggle'
export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
AccessibilityProps &
React.PropsWithChildren<{}>
export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
multiple?: boolean
}
export function Group({children, multiple, ...props}: GroupProps) {
const t = useTheme()
return (
<Toggle.Group type={multiple ? 'checkbox' : 'radio'} {...props}>
<View
style={[
a.flex_row,
a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border,
]}>
{children}
</View>
</Toggle.Group>
)
}
export function Button({children, ...props}: ItemProps) {
return (
<Toggle.Item {...props}>
<ButtonInner>{children}</ButtonInner>
</Toggle.Item>
)
}
function ButtonInner({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const state = Toggle.useItemContext()
const {baseStyles, hoverStyles, activeStyles, textStyles} =
React.useMemo(() => {
const base: ViewStyle[] = []
const hover: ViewStyle[] = []
const active: ViewStyle[] = []
const text: TextStyle[] = []
hover.push(
t.name === 'light' ? t.atoms.bg_contrast_100 : t.atoms.bg_contrast_25,
)
if (state.selected) {
active.push({
backgroundColor: t.palette.contrast_800,
})
text.push(t.atoms.text_inverted)
hover.push({
backgroundColor: t.palette.contrast_800,
})
if (state.disabled) {
active.push({
backgroundColor: t.palette.contrast_500,
})
}
}
if (state.disabled) {
base.push({
backgroundColor: t.palette.contrast_100,
})
text.push({
opacity: 0.5,
})
}
return {
baseStyles: base,
hoverStyles: hover,
activeStyles: active,
textStyles: text,
}
}, [t, state])
return (
<View
style={[
{
borderLeftWidth: 1,
marginLeft: -1,
},
a.px_lg,
a.py_md,
native({
paddingTop: 14,
}),
t.atoms.bg,
t.atoms.border,
baseStyles,
activeStyles,
(state.hovered || state.focused || state.pressed) && hoverStyles,
]}>
{typeof children === 'string' ? (
<Text
style={[
a.text_center,
a.font_bold,
t.atoms.text_contrast_500,
textStyles,
]}>
{children}
</Text>
) : (
children
)}
</View>
)
}
@@ -0,0 +1,21 @@
import React from 'react'
export function useInteractionState() {
const [state, setState] = React.useState(false)
const onIn = React.useCallback(() => {
setState(true)
}, [setState])
const onOut = React.useCallback(() => {
setState(false)
}, [setState])
return React.useMemo(
() => ({
state,
onIn,
onOut,
}),
[state, onIn, onOut],
)
}
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const CalendarDays_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V9h14v10H5ZM5 7h14V5H5v2Zm3 10.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM17.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 13.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 17.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ColorPalette_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 12c0-4.09 3.527-7.5 8-7.5s8 3.41 8 7.5c0 1.579-.419 2.056-.708 2.236-.388.241-1.031.286-2.058.153-.33-.043-.652-.096-.991-.152a65.905 65.905 0 0 0-.531-.087c-.52-.081-1.077-.156-1.61-.164-1.065-.016-2.336.245-2.996 1.567-.418.834-.295 1.67-.078 2.314.18.534.47 1.055.683 1.437v.001l.097.175.01.018C7.432 19.407 4 16.033 4 12Zm8-9.5C6.532 2.5 2 6.7 2 12s4.532 9.5 10 9.5c.401 0 .812-.04 1.166-.193.41-.176.761-.517.866-1.028.085-.416-.03-.796-.118-1.029a5.981 5.981 0 0 0-.351-.73l-.12-.215c-.215-.392-.403-.73-.52-1.078-.13-.387-.111-.614-.029-.78.146-.291.404-.473 1.178-.461.385.005.825.06 1.329.14.15.023.308.05.47.077.36.059.742.122 1.105.17 1.021.132 2.325.213 3.373-.439C21.496 15.22 22 13.874 22 12c0-5.3-4.532-9.5-10-9.5Zm3.5 8.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM9 12.25a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm1.5-2.75a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Globe_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z',
})
+48
View File
@@ -0,0 +1,48 @@
import React from 'react'
import Svg, {Path} from 'react-native-svg'
import {useCommonSVGProps, Props} from '#/components/icons/common'
export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef(
function LogoImpl(props: Props, ref) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
return (
<Svg
fill="none"
{...rest}
// @ts-ignore it's fiiiiine
ref={ref}
viewBox="0 0 24 24"
width={size}
height={size}
style={[style]}>
<Path
fill={fill}
fillRule="evenodd"
clipRule="evenodd"
d="M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z"
/>
</Svg>
)
},
)
export function createSinglePathSVG({path}: {path: string}) {
return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
return (
<Svg
fill="none"
{...rest}
ref={ref}
viewBox="0 0 24 24"
width={size}
height={size}
style={[style]}>
<Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} />
</Svg>
)
})
}
+32
View File
@@ -0,0 +1,32 @@
import {StyleSheet, TextProps} from 'react-native'
import type {SvgProps, PathProps} from 'react-native-svg'
import {tokens} from '#/alf'
export type Props = {
fill?: PathProps['fill']
style?: TextProps['style']
size?: keyof typeof sizes
} & Omit<SvgProps, 'style' | 'size'>
export const sizes = {
xs: 12,
sm: 16,
md: 20,
lg: 24,
xl: 28,
}
export function useCommonSVGProps(props: Props) {
const {fill, size, ...rest} = props
const style = StyleSheet.flatten(rest.style)
const _fill = fill || style?.color || tokens.color.blue_500
const _size = Number(size ? sizes[size] : rest.width || sizes.md)
return {
fill: _fill,
size: _size,
style,
...rest,
}
}
+18 -9
View File
@@ -4,15 +4,20 @@ import {
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
import {getContentLanguages} from '#/state/preferences/languages'
export class CustomFeedAPI implements FeedAPI {
constructor(public params: GetCustomFeed.QueryParams) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().app.bsky.feed.getFeed({
...this.params,
limit: 1,
})
const contentLangs = getContentLanguages().join(',')
const res = await getAgent().app.bsky.feed.getFeed(
{
...this.params,
limit: 1,
},
{headers: {'Accept-Language': contentLangs}},
)
return res.data.feed[0]
}
@@ -23,11 +28,15 @@ export class CustomFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().app.bsky.feed.getFeed({
...this.params,
cursor,
limit,
})
const contentLangs = getContentLanguages().join(',')
const res = await getAgent().app.bsky.feed.getFeed(
{
...this.params,
cursor,
limit,
},
{headers: {'Accept-Language': contentLangs}},
)
if (res.success) {
// NOTE
// some custom feeds fail to enforce the pagination limit
+89
View File
@@ -0,0 +1,89 @@
import {AppBskyFeedDefs} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {FollowingFeedAPI} from './following'
import {CustomFeedAPI} from './custom'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
// HACK
// the feed API does not include any facilities for passing down
// non-post elements. adding that is a bit of a heavy lift, and we
// have just one temporary usecase for it: flagging when the home feed
// falls back to discover.
// we use this fallback marker post to drive this instead. see Feed.tsx
// for the usage.
// -prf
export const FALLBACK_MARKER_POST: AppBskyFeedDefs.FeedViewPost = {
post: {
uri: 'fallback-marker-post',
cid: 'fake',
record: {},
author: {
did: 'did:fake',
handle: 'fake.com',
},
indexedAt: new Date().toISOString(),
},
}
export class HomeFeedAPI implements FeedAPI {
following: FollowingFeedAPI
discover: CustomFeedAPI
usingDiscover = false
itemCursor = 0
constructor() {
this.following = new FollowingFeedAPI()
this.discover = new CustomFeedAPI({feed: PROD_DEFAULT_FEED('whats-hot')})
}
reset() {
this.following = new FollowingFeedAPI()
this.discover = new CustomFeedAPI({feed: PROD_DEFAULT_FEED('whats-hot')})
this.usingDiscover = false
this.itemCursor = 0
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
if (this.usingDiscover) {
return this.discover.peekLatest()
}
return this.following.peekLatest()
}
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
if (!cursor) {
this.reset()
}
let returnCursor
let posts: AppBskyFeedDefs.FeedViewPost[] = []
if (!this.usingDiscover) {
const res = await this.following.fetch({cursor, limit})
returnCursor = res.cursor
posts = posts.concat(res.feed)
if (!returnCursor) {
cursor = ''
posts.push(FALLBACK_MARKER_POST)
this.usingDiscover = true
}
}
if (this.usingDiscover) {
const res = await this.discover.fetch({cursor, limit})
returnCursor = res.cursor
posts = posts.concat(res.feed)
}
return {
cursor: returnCursor,
feed: posts,
}
}
}
+11 -6
View File
@@ -8,6 +8,7 @@ import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
import {FeedParams} from '#/state/queries/post-feed'
import {FeedTunerFn} from '../feed-manip'
import {getAgent} from '#/state/session'
import {getContentLanguages} from '#/state/preferences/languages'
const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@@ -25,7 +26,7 @@ export class MergeFeedAPI implements FeedAPI {
reset() {
this.following = new MergeFeedSource_Following(this.feedTuners)
this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
this.customFeeds = []
this.feedCursor = 0
this.itemCursor = 0
this.sampleCursor = 0
@@ -231,11 +232,15 @@ class MergeFeedSource_Custom extends MergeFeedSource {
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
try {
const res = await getAgent().app.bsky.feed.getFeed({
cursor,
limit,
feed: this.feedUri,
})
const contentLangs = getContentLanguages().join(',')
const res = await getAgent().app.bsky.feed.getFeed(
{
cursor,
limit,
feed: this.feedUri,
},
{headers: {'Accept-Language': contentLangs}},
)
// NOTE
// some custom feeds fail to enforce the pagination limit
// so we manually truncate here
+4 -2
View File
@@ -68,11 +68,12 @@ export function parseEmbedPlayerFromUrl(
// youtube
if (urlp.hostname === 'youtu.be') {
const videoId = urlp.pathname.split('/')[1]
const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
if (videoId) {
return {
type: 'youtube_video',
source: 'youtube',
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1&start=${seek}`,
}
}
}
@@ -84,13 +85,14 @@ export function parseEmbedPlayerFromUrl(
const [_, page, shortVideoId] = urlp.pathname.split('/')
const videoId =
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
if (videoId) {
return {
type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
hideDetails: page === 'shorts' ? true : undefined,
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1&start=${seek}`,
}
}
}
+56 -54
View File
@@ -2,30 +2,32 @@ import {Platform} from 'react-native'
import type {Theme} from './ThemeContext'
import {colors} from './styles'
import {darkPalette, lightPalette} from '#/alf/themes'
export const defaultTheme: Theme = {
colorScheme: 'light',
palette: {
default: {
background: colors.white,
backgroundLight: colors.gray1,
text: colors.black,
textLight: colors.gray5,
textInverted: colors.white,
link: colors.blue3,
border: '#f0e9e9',
borderDark: '#e0d9d9',
icon: colors.gray4,
background: lightPalette.white,
backgroundLight: lightPalette.contrast_50,
text: lightPalette.black,
textLight: lightPalette.contrast_700,
textInverted: lightPalette.white,
link: lightPalette.primary_500,
border: lightPalette.contrast_100,
borderDark: lightPalette.contrast_200,
icon: lightPalette.contrast_500,
// non-standard
textVeryLight: colors.gray4,
replyLine: colors.gray2,
replyLineDot: colors.gray3,
unreadNotifBg: '#ebf6ff',
unreadNotifBorder: colors.blue1,
postCtrl: '#71768A',
brandText: '#0066FF',
emptyStateIcon: '#B6B6C9',
borderLinkHover: '#cac1c1',
textVeryLight: lightPalette.contrast_400,
replyLine: lightPalette.contrast_100,
replyLineDot: lightPalette.contrast_200,
unreadNotifBg: lightPalette.primary_25,
unreadNotifBorder: lightPalette.primary_100,
postCtrl: lightPalette.contrast_500,
brandText: lightPalette.primary_500,
emptyStateIcon: lightPalette.contrast_300,
borderLinkHover: lightPalette.contrast_300,
},
primary: {
background: colors.blue3,
@@ -50,15 +52,15 @@ export const defaultTheme: Theme = {
icon: colors.green4,
},
inverted: {
background: colors.black,
backgroundLight: colors.gray6,
text: colors.white,
textLight: colors.gray3,
textInverted: colors.black,
link: colors.blue2,
border: colors.gray3,
borderDark: colors.gray2,
icon: colors.gray5,
background: darkPalette.black,
backgroundLight: darkPalette.contrast_50,
text: darkPalette.white,
textLight: darkPalette.contrast_700,
textInverted: darkPalette.black,
link: darkPalette.primary_500,
border: darkPalette.contrast_100,
borderDark: darkPalette.contrast_200,
icon: darkPalette.contrast_500,
},
error: {
background: colors.red3,
@@ -292,26 +294,26 @@ export const darkTheme: Theme = {
palette: {
...defaultTheme.palette,
default: {
background: colors.black,
backgroundLight: colors.gray7,
text: colors.white,
textLight: colors.gray3,
textInverted: colors.black,
link: colors.blue3,
border: colors.gray7,
borderDark: colors.gray6,
icon: colors.gray4,
background: darkPalette.black,
backgroundLight: darkPalette.contrast_50,
text: darkPalette.white,
textLight: darkPalette.contrast_700,
textInverted: darkPalette.black,
link: darkPalette.primary_500,
border: darkPalette.contrast_100,
borderDark: darkPalette.contrast_200,
icon: darkPalette.contrast_500,
// non-standard
textVeryLight: colors.gray4,
replyLine: colors.gray5,
replyLineDot: colors.gray6,
unreadNotifBg: colors.blue7,
unreadNotifBorder: colors.blue6,
postCtrl: '#707489',
brandText: '#0085ff',
emptyStateIcon: colors.gray4,
borderLinkHover: colors.gray5,
textVeryLight: darkPalette.contrast_400,
replyLine: darkPalette.contrast_100,
replyLineDot: darkPalette.contrast_200,
unreadNotifBg: darkPalette.primary_975,
unreadNotifBorder: darkPalette.primary_900,
postCtrl: darkPalette.contrast_500,
brandText: darkPalette.primary_500,
emptyStateIcon: darkPalette.contrast_300,
borderLinkHover: darkPalette.contrast_300,
},
primary: {
...defaultTheme.palette.primary,
@@ -322,15 +324,15 @@ export const darkTheme: Theme = {
textInverted: colors.green2,
},
inverted: {
background: colors.white,
backgroundLight: colors.gray2,
text: colors.black,
textLight: colors.gray5,
textInverted: colors.white,
link: colors.blue3,
border: colors.gray3,
borderDark: colors.gray4,
icon: colors.gray1,
background: lightPalette.white,
backgroundLight: lightPalette.contrast_50,
text: lightPalette.black,
textLight: lightPalette.contrast_700,
textInverted: lightPalette.white,
link: lightPalette.primary_500,
border: lightPalette.contrast_100,
borderDark: lightPalette.contrast_200,
icon: lightPalette.contrast_500,
},
},
}
+2
View File
@@ -137,6 +137,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.pt_BR
case 'uk':
return AppLanguage.uk
case 'ca':
return AppLanguage.ca
default:
continue
}
+5
View File
@@ -13,6 +13,7 @@ import {messages as messagesJa} from '#/locale/locales/ja/messages'
import {messages as messagesKo} from '#/locale/locales/ko/messages'
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
import {messages as messagesUk} from '#/locale/locales/uk/messages'
import {messages as messagesCa} from '#/locale/locales/ca/messages'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages'
@@ -59,6 +60,10 @@ export async function dynamicActivate(locale: AppLanguage) {
i18n.loadAndActivate({locale, messages: messagesUk})
break
}
case AppLanguage.ca: {
i18n.loadAndActivate({locale, messages: messagesCa})
break
}
default: {
i18n.loadAndActivate({locale, messages: messagesEn})
break
+4
View File
@@ -49,6 +49,10 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/uk/messages`)
break
}
case AppLanguage.ca: {
mod = await import(`./locales/ca/messages`)
break
}
default: {
mod = await import(`./locales/en/messages`)
break
+2
View File
@@ -16,6 +16,7 @@ export enum AppLanguage {
ko = 'ko',
pt_BR = 'pt-BR',
uk = 'uk',
ca = 'ca',
}
interface AppLanguageConfig {
@@ -35,6 +36,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.ko, name: '한국어'},
{code2: AppLanguage.pt_BR, name: 'Português (BR)'},
{code2: AppLanguage.uk, name: 'Українська'},
{code2: AppLanguage.ca, name: 'Catalan'},
]
export const LANGUAGES: Language[] = [
File diff suppressed because it is too large Load Diff
+48 -39
View File
@@ -345,11 +345,6 @@ msgstr ""
msgid "Artistic or non-erotic nudity."
msgstr "Künstlerische oder nicht-erotische Nacktheit."
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:141
#: src/view/com/auth/login/ChooseAccountForm.tsx:151
#: src/view/com/auth/login/ForgotPasswordForm.tsx:170
@@ -364,6 +359,11 @@ msgstr ""
msgid "Back"
msgstr "Zurück"
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/screens/Settings.tsx:489
msgid "Basics"
msgstr "Grundlagen"
@@ -398,6 +398,7 @@ msgstr "Diese Kontos sperren?"
msgid "Block this List"
msgstr ""
#: src/view/com/lists/ListCard.tsx:109
#: src/view/com/util/post-embeds/QuoteEmbed.tsx:57
msgid "Blocked"
msgstr ""
@@ -500,15 +501,6 @@ msgstr "Kamera"
msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long."
msgstr "Darf nur Buchstaben, Zahlen, Leerzeichen, Bindestriche und Unterstriche enthalten. Muss mindestens 4 Zeichen lang sein, darf aber nicht länger als 32 Zeichen sein."
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:227
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/composer/Composer.tsx:300
#: src/view/com/composer/Composer.tsx:305
#: src/view/com/modals/ChangeEmail.tsx:218
@@ -527,8 +519,17 @@ msgstr ""
msgid "Cancel"
msgstr "Abbrechen"
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:230
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:148
#: src/view/com/modals/DeleteAccount.tsx:223
#: src/view/com/modals/DeleteAccount.tsx:226
msgid "Cancel account deletion"
msgstr "Kontolöschung abbrechen"
@@ -704,12 +705,6 @@ msgstr ""
msgid "Compose reply"
msgstr "Antwort zusammenstellen"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:98
#: src/view/com/modals/SelfLabel.tsx:154
#: src/view/com/modals/VerifyEmail.tsx:231
@@ -719,6 +714,12 @@ msgstr ""
msgid "Confirm"
msgstr "Bestätigen"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/ChangeEmail.tsx:193
#: src/view/com/modals/ChangeEmail.tsx:195
msgid "Confirm Change"
@@ -728,7 +729,7 @@ msgstr "Änderung bestätigen"
msgid "Confirm content language settings"
msgstr "Bestätigen Sie die Spracheinstellungen für den Inhalt"
#: src/view/com/modals/DeleteAccount.tsx:213
#: src/view/com/modals/DeleteAccount.tsx:216
msgid "Confirm delete account"
msgstr "Bestätigen Sie Konto löschen"
@@ -920,7 +921,7 @@ msgstr "App-Passwort löschen"
msgid "Delete List"
msgstr "Liste löschen"
#: src/view/com/modals/DeleteAccount.tsx:216
#: src/view/com/modals/DeleteAccount.tsx:219
msgid "Delete my account"
msgstr "Mein Konto löschen"
@@ -1624,7 +1625,7 @@ msgstr ""
msgid "Input new password"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:196
#: src/view/com/modals/DeleteAccount.tsx:199
msgid "Input password for account deletion"
msgstr ""
@@ -2014,6 +2015,10 @@ msgstr ""
msgid "Mute thread"
msgstr "Thema stummschalten"
#: src/view/com/lists/ListCard.tsx:101
msgid "Muted"
msgstr ""
#: src/view/screens/Moderation.tsx:109
msgid "Muted accounts"
msgstr "Stumme Kontos"
@@ -2124,11 +2129,6 @@ msgstr ""
msgid "Newest replies first"
msgstr ""
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:154
#: src/view/com/auth/login/ForgotPasswordForm.tsx:178
#: src/view/com/auth/login/ForgotPasswordForm.tsx:188
@@ -2139,6 +2139,11 @@ msgstr ""
msgid "Next"
msgstr "Nächster"
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:142
msgid "Next image"
msgstr "Nächstes Bild "
@@ -2395,7 +2400,7 @@ msgstr "Seite nicht gefunden"
#: src/view/com/auth/create/Step2.tsx:132
#: src/view/com/auth/login/LoginForm.tsx:223
#: src/view/com/auth/login/SetNewPasswordForm.tsx:132
#: src/view/com/modals/DeleteAccount.tsx:195
#: src/view/com/modals/DeleteAccount.tsx:198
msgid "Password"
msgstr "Passwort"
@@ -2473,7 +2478,7 @@ msgstr "Bitte geben Sie einen eindeutigen Namen für dieses App-Passwort ein ode
msgid "Please enter your email."
msgstr "Bitte geben Sie Ihre E-Mail-Adresse ein."
#: src/view/com/modals/DeleteAccount.tsx:184
#: src/view/com/modals/DeleteAccount.tsx:187
msgid "Please enter your password as well:"
msgstr "Bitte geben Sie auch Ihr Passwort ein:"
@@ -3315,8 +3320,8 @@ msgid "Subscribe to this list"
msgstr "Abonnieren Sie diese Liste"
#: src/view/com/lists/ListCard.tsx:101
msgid "Subscribed"
msgstr ""
#~ msgid "Subscribed"
#~ msgstr ""
#: src/view/screens/Search/Search.tsx:362
msgid "Suggested Follows"
@@ -3450,7 +3455,7 @@ msgstr ""
msgid "There was an issue fetching notifications. Tap here to try again."
msgstr ""
#: src/view/com/posts/Feed.tsx:261
#: src/view/com/posts/Feed.tsx:263
msgid "There was an issue fetching posts. Tap here to try again."
msgstr ""
@@ -3628,16 +3633,16 @@ msgstr "Stummschaltliste aufheben"
msgid "Unable to contact your service. Please check your Internet connection."
msgstr "Sie können Ihren Dienst nicht kontaktieren. Bitte überprüfen Sie Ihre Internetverbindung."
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:472
#: src/view/screens/ProfileList.tsx:568
msgid "Unblock"
msgstr "Freischalten"
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:308
#: src/view/com/profile/ProfileHeader.tsx:392
msgid "Unblock Account"
@@ -3847,6 +3852,10 @@ msgstr "Seite besuchen"
msgid "Warn"
msgstr ""
#: src/view/com/posts/DiscoverFallbackHeader.tsx:29
msgid "We ran out of posts from your follows. Here's the latest from"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:48
msgid "We'll look into your appeal promptly."
msgstr ""
+47 -38
View File
@@ -373,11 +373,6 @@ msgstr ""
#~ msgid "Ask apps to limit the visibility of my account"
#~ msgstr ""
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:141
#: src/view/com/auth/login/ChooseAccountForm.tsx:151
#: src/view/com/auth/login/ForgotPasswordForm.tsx:170
@@ -392,6 +387,11 @@ msgstr ""
msgid "Back"
msgstr ""
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/screens/Settings.tsx:489
msgid "Basics"
msgstr ""
@@ -426,6 +426,7 @@ msgstr ""
msgid "Block this List"
msgstr ""
#: src/view/com/lists/ListCard.tsx:109
#: src/view/com/util/post-embeds/QuoteEmbed.tsx:57
msgid "Blocked"
msgstr ""
@@ -528,15 +529,6 @@ msgstr ""
msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long."
msgstr ""
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:227
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/composer/Composer.tsx:300
#: src/view/com/composer/Composer.tsx:305
#: src/view/com/modals/ChangeEmail.tsx:218
@@ -555,8 +547,17 @@ msgstr ""
msgid "Cancel"
msgstr ""
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:230
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:148
#: src/view/com/modals/DeleteAccount.tsx:223
#: src/view/com/modals/DeleteAccount.tsx:226
msgid "Cancel account deletion"
msgstr ""
@@ -736,12 +737,6 @@ msgstr ""
msgid "Compose reply"
msgstr ""
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:98
#: src/view/com/modals/SelfLabel.tsx:154
#: src/view/com/modals/VerifyEmail.tsx:231
@@ -751,6 +746,12 @@ msgstr ""
msgid "Confirm"
msgstr ""
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/ChangeEmail.tsx:193
#: src/view/com/modals/ChangeEmail.tsx:195
msgid "Confirm Change"
@@ -760,7 +761,7 @@ msgstr ""
msgid "Confirm content language settings"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:213
#: src/view/com/modals/DeleteAccount.tsx:216
msgid "Confirm delete account"
msgstr ""
@@ -952,7 +953,7 @@ msgstr ""
msgid "Delete List"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:216
#: src/view/com/modals/DeleteAccount.tsx:219
msgid "Delete my account"
msgstr ""
@@ -1673,7 +1674,7 @@ msgstr ""
msgid "Input new password"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:196
#: src/view/com/modals/DeleteAccount.tsx:199
msgid "Input password for account deletion"
msgstr ""
@@ -2075,6 +2076,10 @@ msgstr ""
msgid "Mute thread"
msgstr ""
#: src/view/com/lists/ListCard.tsx:101
msgid "Muted"
msgstr ""
#: src/view/screens/Moderation.tsx:109
msgid "Muted accounts"
msgstr ""
@@ -2189,11 +2194,6 @@ msgstr ""
msgid "Newest replies first"
msgstr ""
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:154
#: src/view/com/auth/login/ForgotPasswordForm.tsx:178
#: src/view/com/auth/login/ForgotPasswordForm.tsx:188
@@ -2204,6 +2204,11 @@ msgstr ""
msgid "Next"
msgstr ""
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:142
msgid "Next image"
msgstr ""
@@ -2477,7 +2482,7 @@ msgstr ""
#: src/view/com/auth/create/Step2.tsx:132
#: src/view/com/auth/login/LoginForm.tsx:223
#: src/view/com/auth/login/SetNewPasswordForm.tsx:132
#: src/view/com/modals/DeleteAccount.tsx:195
#: src/view/com/modals/DeleteAccount.tsx:198
msgid "Password"
msgstr ""
@@ -2555,7 +2560,7 @@ msgstr ""
msgid "Please enter your email."
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:184
#: src/view/com/modals/DeleteAccount.tsx:187
msgid "Please enter your password as well:"
msgstr ""
@@ -3422,8 +3427,8 @@ msgid "Subscribe to this list"
msgstr ""
#: src/view/com/lists/ListCard.tsx:101
msgid "Subscribed"
msgstr ""
#~ msgid "Subscribed"
#~ msgstr ""
#: src/view/screens/Search/Search.tsx:362
msgid "Suggested Follows"
@@ -3557,7 +3562,7 @@ msgstr ""
msgid "There was an issue fetching notifications. Tap here to try again."
msgstr ""
#: src/view/com/posts/Feed.tsx:261
#: src/view/com/posts/Feed.tsx:263
msgid "There was an issue fetching posts. Tap here to try again."
msgstr ""
@@ -3735,13 +3740,13 @@ msgstr ""
msgid "Unable to contact your service. Please check your Internet connection."
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
#: src/view/com/profile/ProfileHeader.tsx:472
#: src/view/screens/ProfileList.tsx:568
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:472
#: src/view/screens/ProfileList.tsx:568
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
@@ -3958,6 +3963,10 @@ msgstr ""
msgid "Warn"
msgstr ""
#: src/view/com/posts/DiscoverFallbackHeader.tsx:29
msgid "We ran out of posts from your follows. Here's the latest from"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:48
msgid "We'll look into your appeal promptly."
msgstr ""
+48 -39
View File
@@ -341,11 +341,6 @@ msgstr ""
msgid "Artistic or non-erotic nudity."
msgstr "Desnudez artística o no erótica."
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:141
#: src/view/com/auth/login/ChooseAccountForm.tsx:151
#: src/view/com/auth/login/ForgotPasswordForm.tsx:170
@@ -360,6 +355,11 @@ msgstr ""
msgid "Back"
msgstr "Regresar"
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/screens/Settings.tsx:489
msgid "Basics"
msgstr "Conceptos básicos"
@@ -394,6 +394,7 @@ msgstr "¿Bloquear estas cuentas?"
msgid "Block this List"
msgstr ""
#: src/view/com/lists/ListCard.tsx:109
#: src/view/com/util/post-embeds/QuoteEmbed.tsx:57
msgid "Blocked"
msgstr ""
@@ -496,15 +497,6 @@ msgstr "Cámara"
msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long."
msgstr "Sólo puede contener letras, números, espacios, guiones y guiones bajos. Debe tener al menos 4 caracteres, pero no más de 32."
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:227
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/composer/Composer.tsx:300
#: src/view/com/composer/Composer.tsx:305
#: src/view/com/modals/ChangeEmail.tsx:218
@@ -523,8 +515,17 @@ msgstr ""
msgid "Cancel"
msgstr "Cancelar"
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:230
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:148
#: src/view/com/modals/DeleteAccount.tsx:223
#: src/view/com/modals/DeleteAccount.tsx:226
msgid "Cancel account deletion"
msgstr "Cancelar la eliminación de la cuenta"
@@ -700,12 +701,6 @@ msgstr ""
msgid "Compose reply"
msgstr "Redactar la respuesta"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:98
#: src/view/com/modals/SelfLabel.tsx:154
#: src/view/com/modals/VerifyEmail.tsx:231
@@ -715,6 +710,12 @@ msgstr ""
msgid "Confirm"
msgstr "Confirmar"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/ChangeEmail.tsx:193
#: src/view/com/modals/ChangeEmail.tsx:195
msgid "Confirm Change"
@@ -724,7 +725,7 @@ msgstr "Confirmar el cambio"
msgid "Confirm content language settings"
msgstr "Confirmar la configuración del idioma del contenido"
#: src/view/com/modals/DeleteAccount.tsx:213
#: src/view/com/modals/DeleteAccount.tsx:216
msgid "Confirm delete account"
msgstr "Confirmar eliminación de cuenta"
@@ -916,7 +917,7 @@ msgstr "Borrar la contraseña de la app"
msgid "Delete List"
msgstr "Borrar la lista"
#: src/view/com/modals/DeleteAccount.tsx:216
#: src/view/com/modals/DeleteAccount.tsx:219
msgid "Delete my account"
msgstr "Borrar mi cuenta"
@@ -1620,7 +1621,7 @@ msgstr ""
msgid "Input new password"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:196
#: src/view/com/modals/DeleteAccount.tsx:199
msgid "Input password for account deletion"
msgstr ""
@@ -2006,6 +2007,10 @@ msgstr ""
msgid "Mute thread"
msgstr "Silenciar el hilo"
#: src/view/com/lists/ListCard.tsx:101
msgid "Muted"
msgstr ""
#: src/view/screens/Moderation.tsx:109
msgid "Muted accounts"
msgstr "Cuentas silenciadas"
@@ -2116,11 +2121,6 @@ msgstr ""
msgid "Newest replies first"
msgstr ""
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:154
#: src/view/com/auth/login/ForgotPasswordForm.tsx:178
#: src/view/com/auth/login/ForgotPasswordForm.tsx:188
@@ -2131,6 +2131,11 @@ msgstr ""
msgid "Next"
msgstr "Siguiente"
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:142
msgid "Next image"
msgstr "Imagen nueva"
@@ -2387,7 +2392,7 @@ msgstr "Página no encontrada"
#: src/view/com/auth/create/Step2.tsx:132
#: src/view/com/auth/login/LoginForm.tsx:223
#: src/view/com/auth/login/SetNewPasswordForm.tsx:132
#: src/view/com/modals/DeleteAccount.tsx:195
#: src/view/com/modals/DeleteAccount.tsx:198
msgid "Password"
msgstr "Contraseña"
@@ -2465,7 +2470,7 @@ msgstr "Introduce un nombre único para la contraseña de esta app o utiliza una
msgid "Please enter your email."
msgstr "Introduce tu correo electrónico."
#: src/view/com/modals/DeleteAccount.tsx:184
#: src/view/com/modals/DeleteAccount.tsx:187
msgid "Please enter your password as well:"
msgstr "Introduce tu contraseña, también:"
@@ -3302,8 +3307,8 @@ msgid "Subscribe to this list"
msgstr "Suscribirse a esta lista"
#: src/view/com/lists/ListCard.tsx:101
msgid "Subscribed"
msgstr ""
#~ msgid "Subscribed"
#~ msgstr ""
#: src/view/screens/Search/Search.tsx:362
msgid "Suggested Follows"
@@ -3437,7 +3442,7 @@ msgstr ""
msgid "There was an issue fetching notifications. Tap here to try again."
msgstr ""
#: src/view/com/posts/Feed.tsx:261
#: src/view/com/posts/Feed.tsx:263
msgid "There was an issue fetching posts. Tap here to try again."
msgstr ""
@@ -3611,16 +3616,16 @@ msgstr "Desactivar la opción de silenciar la lista"
msgid "Unable to contact your service. Please check your Internet connection."
msgstr "No se puede contactar con tu servicio. Comprueba tu conexión a Internet."
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:472
#: src/view/screens/ProfileList.tsx:568
msgid "Unblock"
msgstr "Desbloquear"
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:308
#: src/view/com/profile/ProfileHeader.tsx:392
msgid "Unblock Account"
@@ -3830,6 +3835,10 @@ msgstr "Visitar el sitio"
msgid "Warn"
msgstr ""
#: src/view/com/posts/DiscoverFallbackHeader.tsx:29
msgid "We ran out of posts from your follows. Here's the latest from"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:48
msgid "We'll look into your appeal promptly."
msgstr ""
File diff suppressed because it is too large Load Diff
+48 -39
View File
@@ -373,11 +373,6 @@ msgstr "कलात्मक या गैर-कामुक नग्नत
#~ msgid "Ask apps to limit the visibility of my account"
#~ msgstr ""
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:141
#: src/view/com/auth/login/ChooseAccountForm.tsx:151
#: src/view/com/auth/login/ForgotPasswordForm.tsx:170
@@ -392,6 +387,11 @@ msgstr ""
msgid "Back"
msgstr "वापस"
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/screens/Settings.tsx:489
msgid "Basics"
msgstr "मूल बातें"
@@ -426,6 +426,7 @@ msgstr "खाता ब्लॉक करें?"
msgid "Block this List"
msgstr ""
#: src/view/com/lists/ListCard.tsx:109
#: src/view/com/util/post-embeds/QuoteEmbed.tsx:57
msgid "Blocked"
msgstr ""
@@ -528,15 +529,6 @@ msgstr "कैमरा"
msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long."
msgstr "केवल अक्षर, संख्या, रिक्त स्थान, डैश और अंडरस्कोर हो सकते हैं। कम से कम 4 अक्षर लंबा होना चाहिए, लेकिन 32 अक्षरों से अधिक लंबा नहीं होना चाहिए।।"
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:227
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/composer/Composer.tsx:300
#: src/view/com/composer/Composer.tsx:305
#: src/view/com/modals/ChangeEmail.tsx:218
@@ -555,8 +547,17 @@ msgstr ""
msgid "Cancel"
msgstr "कैंसिल"
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:230
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:148
#: src/view/com/modals/DeleteAccount.tsx:223
#: src/view/com/modals/DeleteAccount.tsx:226
msgid "Cancel account deletion"
msgstr "अकाउंट बंद मत करो"
@@ -732,12 +733,6 @@ msgstr ""
msgid "Compose reply"
msgstr "जवाब लिखो"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:98
#: src/view/com/modals/SelfLabel.tsx:154
#: src/view/com/modals/VerifyEmail.tsx:231
@@ -747,6 +742,12 @@ msgstr ""
msgid "Confirm"
msgstr "हो गया"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/ChangeEmail.tsx:193
#: src/view/com/modals/ChangeEmail.tsx:195
msgid "Confirm Change"
@@ -756,7 +757,7 @@ msgstr "बदलाव की पुष्टि करें"
msgid "Confirm content language settings"
msgstr "सामग्री भाषा सेटिंग्स की पुष्टि करें"
#: src/view/com/modals/DeleteAccount.tsx:213
#: src/view/com/modals/DeleteAccount.tsx:216
msgid "Confirm delete account"
msgstr "खाते को हटा दें"
@@ -948,7 +949,7 @@ msgstr "अप्प पासवर्ड हटाएं"
msgid "Delete List"
msgstr "सूची हटाएँ"
#: src/view/com/modals/DeleteAccount.tsx:216
#: src/view/com/modals/DeleteAccount.tsx:219
msgid "Delete my account"
msgstr "मेरा खाता हटाएं"
@@ -1665,7 +1666,7 @@ msgstr ""
msgid "Input new password"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:196
#: src/view/com/modals/DeleteAccount.tsx:199
msgid "Input password for account deletion"
msgstr ""
@@ -2067,6 +2068,10 @@ msgstr ""
msgid "Mute thread"
msgstr "थ्रेड म्यूट करें"
#: src/view/com/lists/ListCard.tsx:101
msgid "Muted"
msgstr ""
#: src/view/screens/Moderation.tsx:109
msgid "Muted accounts"
msgstr "म्यूट किए गए खाते"
@@ -2181,11 +2186,6 @@ msgstr ""
msgid "Newest replies first"
msgstr ""
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:154
#: src/view/com/auth/login/ForgotPasswordForm.tsx:178
#: src/view/com/auth/login/ForgotPasswordForm.tsx:188
@@ -2196,6 +2196,11 @@ msgstr ""
msgid "Next"
msgstr "अगला"
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:142
msgid "Next image"
msgstr "अगली फोटो"
@@ -2469,7 +2474,7 @@ msgstr "पृष्ठ नहीं मिला"
#: src/view/com/auth/create/Step2.tsx:132
#: src/view/com/auth/login/LoginForm.tsx:223
#: src/view/com/auth/login/SetNewPasswordForm.tsx:132
#: src/view/com/modals/DeleteAccount.tsx:195
#: src/view/com/modals/DeleteAccount.tsx:198
msgid "Password"
msgstr "पासवर्ड"
@@ -2547,7 +2552,7 @@ msgstr "कृपया इस ऐप पासवर्ड के लिए ए
msgid "Please enter your email."
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:184
#: src/view/com/modals/DeleteAccount.tsx:187
msgid "Please enter your password as well:"
msgstr "कृपया अपना पासवर्ड भी दर्ज करें:"
@@ -3414,8 +3419,8 @@ msgid "Subscribe to this list"
msgstr "इस सूची को सब्सक्राइब करें"
#: src/view/com/lists/ListCard.tsx:101
msgid "Subscribed"
msgstr ""
#~ msgid "Subscribed"
#~ msgstr ""
#: src/view/screens/Search/Search.tsx:362
msgid "Suggested Follows"
@@ -3549,7 +3554,7 @@ msgstr ""
msgid "There was an issue fetching notifications. Tap here to try again."
msgstr ""
#: src/view/com/posts/Feed.tsx:261
#: src/view/com/posts/Feed.tsx:263
msgid "There was an issue fetching posts. Tap here to try again."
msgstr ""
@@ -3727,16 +3732,16 @@ msgstr ""
msgid "Unable to contact your service. Please check your Internet connection."
msgstr "आपकी सेवा से संपर्क करने में असमर्थ। कृपया अपने इंटरनेट कनेक्शन की जांच करें।।"
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:472
#: src/view/screens/ProfileList.tsx:568
msgid "Unblock"
msgstr "अनब्लॉक"
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:308
#: src/view/com/profile/ProfileHeader.tsx:392
msgid "Unblock Account"
@@ -3950,6 +3955,10 @@ msgstr "साइट पर जाएं"
msgid "Warn"
msgstr ""
#: src/view/com/posts/DiscoverFallbackHeader.tsx:29
msgid "We ran out of posts from your follows. Here's the latest from"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:48
msgid "We'll look into your appeal promptly."
msgstr ""
+48 -39
View File
@@ -345,11 +345,6 @@ msgstr ""
msgid "Artistic or non-erotic nudity."
msgstr "Ketelanjangan artistik atau non-erotis."
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:141
#: src/view/com/auth/login/ChooseAccountForm.tsx:151
#: src/view/com/auth/login/ForgotPasswordForm.tsx:170
@@ -364,6 +359,11 @@ msgstr ""
msgid "Back"
msgstr "Kembali"
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/screens/Settings.tsx:489
msgid "Basics"
msgstr "Dasar"
@@ -398,6 +398,7 @@ msgstr "Blokir akun ini?"
msgid "Block this List"
msgstr ""
#: src/view/com/lists/ListCard.tsx:109
#: src/view/com/util/post-embeds/QuoteEmbed.tsx:57
msgid "Blocked"
msgstr ""
@@ -500,15 +501,6 @@ msgstr "Kamera"
msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long."
msgstr "Hanya dapat terdiri dari huruf, angka, spasi, tanda hubung dan garis bawah. Minimal 4 karakter, namun tidak boleh lebih dari 32 karakter."
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:227
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/composer/Composer.tsx:300
#: src/view/com/composer/Composer.tsx:305
#: src/view/com/modals/ChangeEmail.tsx:218
@@ -527,8 +519,17 @@ msgstr ""
msgid "Cancel"
msgstr "Batal"
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:230
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:148
#: src/view/com/modals/DeleteAccount.tsx:223
#: src/view/com/modals/DeleteAccount.tsx:226
msgid "Cancel account deletion"
msgstr "Batal menghapus akun"
@@ -704,12 +705,6 @@ msgstr ""
msgid "Compose reply"
msgstr "Tulis balasan"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:98
#: src/view/com/modals/SelfLabel.tsx:154
#: src/view/com/modals/VerifyEmail.tsx:231
@@ -719,6 +714,12 @@ msgstr ""
msgid "Confirm"
msgstr "Konfirmasi"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/ChangeEmail.tsx:193
#: src/view/com/modals/ChangeEmail.tsx:195
msgid "Confirm Change"
@@ -728,7 +729,7 @@ msgstr "Konfirmasi Perubahan"
msgid "Confirm content language settings"
msgstr "Konfirmasi pengaturan bahasa konten"
#: src/view/com/modals/DeleteAccount.tsx:213
#: src/view/com/modals/DeleteAccount.tsx:216
msgid "Confirm delete account"
msgstr "Konfirmasi hapus akun"
@@ -920,7 +921,7 @@ msgstr "Hapus kata sandi aplikasi"
msgid "Delete List"
msgstr "Hapus Daftar "
#: src/view/com/modals/DeleteAccount.tsx:216
#: src/view/com/modals/DeleteAccount.tsx:219
msgid "Delete my account"
msgstr "Hapus akun saya"
@@ -1624,7 +1625,7 @@ msgstr ""
msgid "Input new password"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:196
#: src/view/com/modals/DeleteAccount.tsx:199
msgid "Input password for account deletion"
msgstr ""
@@ -2014,6 +2015,10 @@ msgstr ""
msgid "Mute thread"
msgstr "Bisukan utasan"
#: src/view/com/lists/ListCard.tsx:101
msgid "Muted"
msgstr ""
#: src/view/screens/Moderation.tsx:109
msgid "Muted accounts"
msgstr "Akun yang dibisukan"
@@ -2124,11 +2129,6 @@ msgstr ""
msgid "Newest replies first"
msgstr ""
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:154
#: src/view/com/auth/login/ForgotPasswordForm.tsx:178
#: src/view/com/auth/login/ForgotPasswordForm.tsx:188
@@ -2139,6 +2139,11 @@ msgstr ""
msgid "Next"
msgstr "Berikutnya"
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:142
msgid "Next image"
msgstr "Gambar berikutnya"
@@ -2395,7 +2400,7 @@ msgstr "Halaman tidak ditemukan"
#: src/view/com/auth/create/Step2.tsx:132
#: src/view/com/auth/login/LoginForm.tsx:223
#: src/view/com/auth/login/SetNewPasswordForm.tsx:132
#: src/view/com/modals/DeleteAccount.tsx:195
#: src/view/com/modals/DeleteAccount.tsx:198
msgid "Password"
msgstr "Kata sandi"
@@ -2473,7 +2478,7 @@ msgstr "Mohon masukkan nama unik untuk Kata Sandi Aplikasi ini atau gunakan nama
msgid "Please enter your email."
msgstr "Masukkan email Anda."
#: src/view/com/modals/DeleteAccount.tsx:184
#: src/view/com/modals/DeleteAccount.tsx:187
msgid "Please enter your password as well:"
msgstr "Masukkan juga kata sandi Anda:"
@@ -3315,8 +3320,8 @@ msgid "Subscribe to this list"
msgstr "Langganan ke daftar ini"
#: src/view/com/lists/ListCard.tsx:101
msgid "Subscribed"
msgstr ""
#~ msgid "Subscribed"
#~ msgstr ""
#: src/view/screens/Search/Search.tsx:362
msgid "Suggested Follows"
@@ -3450,7 +3455,7 @@ msgstr ""
msgid "There was an issue fetching notifications. Tap here to try again."
msgstr ""
#: src/view/com/posts/Feed.tsx:261
#: src/view/com/posts/Feed.tsx:263
msgid "There was an issue fetching posts. Tap here to try again."
msgstr ""
@@ -3628,16 +3633,16 @@ msgstr "Bunyikan daftar"
msgid "Unable to contact your service. Please check your Internet connection."
msgstr "Tidak dapat terhubung ke layanan. Mohon periksa koneksi internet Anda."
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:472
#: src/view/screens/ProfileList.tsx:568
msgid "Unblock"
msgstr "Buka blokir"
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:308
#: src/view/com/profile/ProfileHeader.tsx:392
msgid "Unblock Account"
@@ -3847,6 +3852,10 @@ msgstr "Kunjungi Halaman"
msgid "Warn"
msgstr ""
#: src/view/com/posts/DiscoverFallbackHeader.tsx:29
msgid "We ran out of posts from your follows. Here's the latest from"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:48
msgid "We'll look into your appeal promptly."
msgstr ""
+48 -39
View File
@@ -345,11 +345,6 @@ msgstr ""
msgid "Artistic or non-erotic nudity."
msgstr "芸術的または性的ではないヌード。"
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:141
#: src/view/com/auth/login/ChooseAccountForm.tsx:151
#: src/view/com/auth/login/ForgotPasswordForm.tsx:170
@@ -364,6 +359,11 @@ msgstr ""
msgid "Back"
msgstr "戻る"
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/screens/Settings.tsx:489
msgid "Basics"
msgstr "基本"
@@ -398,6 +398,7 @@ msgstr "これらのアカウントをブロックしますか?"
msgid "Block this List"
msgstr ""
#: src/view/com/lists/ListCard.tsx:109
#: src/view/com/util/post-embeds/QuoteEmbed.tsx:57
msgid "Blocked"
msgstr ""
@@ -500,15 +501,6 @@ msgstr "カメラ"
msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long."
msgstr "文字、数字、スペース、ハイフン、およびアンダースコアのみが使用可能です。長さは4文字以上32文字以下である必要があります。"
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:227
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/composer/Composer.tsx:300
#: src/view/com/composer/Composer.tsx:305
#: src/view/com/modals/ChangeEmail.tsx:218
@@ -527,8 +519,17 @@ msgstr ""
msgid "Cancel"
msgstr "キャンセル"
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:230
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:148
#: src/view/com/modals/DeleteAccount.tsx:223
#: src/view/com/modals/DeleteAccount.tsx:226
msgid "Cancel account deletion"
msgstr "アカウントの削除をキャンセル"
@@ -704,12 +705,6 @@ msgstr ""
msgid "Compose reply"
msgstr "返信を作成"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:98
#: src/view/com/modals/SelfLabel.tsx:154
#: src/view/com/modals/VerifyEmail.tsx:231
@@ -719,6 +714,12 @@ msgstr ""
msgid "Confirm"
msgstr "確認"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/ChangeEmail.tsx:193
#: src/view/com/modals/ChangeEmail.tsx:195
msgid "Confirm Change"
@@ -728,7 +729,7 @@ msgstr "変更を確認"
msgid "Confirm content language settings"
msgstr "コンテンツの言語設定を確認"
#: src/view/com/modals/DeleteAccount.tsx:213
#: src/view/com/modals/DeleteAccount.tsx:216
msgid "Confirm delete account"
msgstr "アカウントの削除を確認"
@@ -920,7 +921,7 @@ msgstr "アプリパスワードを削除"
msgid "Delete List"
msgstr "リストを削除"
#: src/view/com/modals/DeleteAccount.tsx:216
#: src/view/com/modals/DeleteAccount.tsx:219
msgid "Delete my account"
msgstr "マイアカウントを削除"
@@ -1624,7 +1625,7 @@ msgstr ""
msgid "Input new password"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:196
#: src/view/com/modals/DeleteAccount.tsx:199
msgid "Input password for account deletion"
msgstr ""
@@ -2022,6 +2023,10 @@ msgstr ""
msgid "Mute thread"
msgstr "スレッドをミュート"
#: src/view/com/lists/ListCard.tsx:101
msgid "Muted"
msgstr ""
#: src/view/screens/Moderation.tsx:109
msgid "Muted accounts"
msgstr "ミュート中のアカウント"
@@ -2132,11 +2137,6 @@ msgstr ""
msgid "Newest replies first"
msgstr "新しい順に返信を表示"
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:154
#: src/view/com/auth/login/ForgotPasswordForm.tsx:178
#: src/view/com/auth/login/ForgotPasswordForm.tsx:188
@@ -2147,6 +2147,11 @@ msgstr ""
msgid "Next"
msgstr "次へ"
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:142
msgid "Next image"
msgstr "次の画像"
@@ -2407,7 +2412,7 @@ msgstr "ページが見つかりません"
#: src/view/com/auth/create/Step2.tsx:132
#: src/view/com/auth/login/LoginForm.tsx:223
#: src/view/com/auth/login/SetNewPasswordForm.tsx:132
#: src/view/com/modals/DeleteAccount.tsx:195
#: src/view/com/modals/DeleteAccount.tsx:198
msgid "Password"
msgstr "パスワード"
@@ -2485,7 +2490,7 @@ msgstr "このアプリパスワードに固有の名前を入力するか、ラ
msgid "Please enter your email."
msgstr "メールアドレスを入力してください。"
#: src/view/com/modals/DeleteAccount.tsx:184
#: src/view/com/modals/DeleteAccount.tsx:187
msgid "Please enter your password as well:"
msgstr "パスワードも入力してください:"
@@ -3331,8 +3336,8 @@ msgid "Subscribe to this list"
msgstr "このリストに登録"
#: src/view/com/lists/ListCard.tsx:101
msgid "Subscribed"
msgstr ""
#~ msgid "Subscribed"
#~ msgstr ""
#: src/view/screens/Search/Search.tsx:362
msgid "Suggested Follows"
@@ -3466,7 +3471,7 @@ msgstr ""
msgid "There was an issue fetching notifications. Tap here to try again."
msgstr ""
#: src/view/com/posts/Feed.tsx:261
#: src/view/com/posts/Feed.tsx:263
msgid "There was an issue fetching posts. Tap here to try again."
msgstr ""
@@ -3644,16 +3649,16 @@ msgstr "リストでのミュートを解除"
msgid "Unable to contact your service. Please check your Internet connection."
msgstr "あなたのサービスに接続できません。インターネットの接続を確認してください。"
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:472
#: src/view/screens/ProfileList.tsx:568
msgid "Unblock"
msgstr "ブロックを解除"
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:308
#: src/view/com/profile/ProfileHeader.tsx:392
msgid "Unblock Account"
@@ -3863,6 +3868,10 @@ msgstr "サイトへアクセス"
msgid "Warn"
msgstr ""
#: src/view/com/posts/DiscoverFallbackHeader.tsx:29
msgid "We ran out of posts from your follows. Here's the latest from"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:48
msgid "We'll look into your appeal promptly."
msgstr ""
+48 -39
View File
@@ -421,11 +421,6 @@ msgstr "선정적이지 않거나 예술적인 노출."
#~ msgid "Ask apps to limit the visibility of my account"
#~ msgstr "내 계정의 공개 범위를 제한하도록 앱에 요청하기"
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:141
#: src/view/com/auth/login/ChooseAccountForm.tsx:151
#: src/view/com/auth/login/ForgotPasswordForm.tsx:170
@@ -440,6 +435,11 @@ msgstr ""
msgid "Back"
msgstr "뒤로"
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/screens/Settings.tsx:489
msgid "Basics"
msgstr "기본"
@@ -474,6 +474,7 @@ msgstr "이 계정들을 차단하시겠습니까?"
msgid "Block this List"
msgstr ""
#: src/view/com/lists/ListCard.tsx:109
#: src/view/com/util/post-embeds/QuoteEmbed.tsx:57
msgid "Blocked"
msgstr ""
@@ -580,15 +581,6 @@ msgstr "카메라"
msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long."
msgstr "글자, 숫자, 공백, 대시, 밑줄만 포함할 수 있습니다. 길이는 4자 이상이어야 하고 32자를 넘지 않아야 합니다."
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:227
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/composer/Composer.tsx:300
#: src/view/com/composer/Composer.tsx:305
#: src/view/com/modals/ChangeEmail.tsx:218
@@ -607,8 +599,17 @@ msgstr ""
msgid "Cancel"
msgstr "취소"
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:230
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:148
#: src/view/com/modals/DeleteAccount.tsx:223
#: src/view/com/modals/DeleteAccount.tsx:226
msgid "Cancel account deletion"
msgstr "계정 삭제 취소"
@@ -788,12 +789,6 @@ msgstr ""
msgid "Compose reply"
msgstr "답글 작성하기"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:98
#: src/view/com/modals/SelfLabel.tsx:154
#: src/view/com/modals/VerifyEmail.tsx:231
@@ -803,6 +798,12 @@ msgstr ""
msgid "Confirm"
msgstr "확인"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/ChangeEmail.tsx:193
#: src/view/com/modals/ChangeEmail.tsx:195
msgid "Confirm Change"
@@ -812,7 +813,7 @@ msgstr "변경 확인"
msgid "Confirm content language settings"
msgstr "콘텐츠 언어 설정 확인"
#: src/view/com/modals/DeleteAccount.tsx:213
#: src/view/com/modals/DeleteAccount.tsx:216
msgid "Confirm delete account"
msgstr "계정 삭제 확인"
@@ -1020,7 +1021,7 @@ msgstr "앱 비밀번호 삭제"
msgid "Delete List"
msgstr "리스트 삭제"
#: src/view/com/modals/DeleteAccount.tsx:216
#: src/view/com/modals/DeleteAccount.tsx:219
msgid "Delete my account"
msgstr "내 계정 삭제"
@@ -1850,7 +1851,7 @@ msgstr ""
msgid "Input new password"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:196
#: src/view/com/modals/DeleteAccount.tsx:199
msgid "Input password for account deletion"
msgstr ""
@@ -2256,6 +2257,10 @@ msgstr ""
msgid "Mute thread"
msgstr "스레드 뮤트"
#: src/view/com/lists/ListCard.tsx:101
msgid "Muted"
msgstr ""
#: src/view/screens/Moderation.tsx:109
msgid "Muted accounts"
msgstr "뮤트한 계정"
@@ -2379,11 +2384,6 @@ msgstr ""
msgid "Newest replies first"
msgstr "새로운 순"
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:154
#: src/view/com/auth/login/ForgotPasswordForm.tsx:178
#: src/view/com/auth/login/ForgotPasswordForm.tsx:188
@@ -2394,6 +2394,11 @@ msgstr ""
msgid "Next"
msgstr "다음"
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:142
msgid "Next image"
msgstr "다음 이미지"
@@ -2688,7 +2693,7 @@ msgstr "페이지를 찾을 수 없음"
#: src/view/com/auth/create/Step2.tsx:132
#: src/view/com/auth/login/LoginForm.tsx:223
#: src/view/com/auth/login/SetNewPasswordForm.tsx:132
#: src/view/com/modals/DeleteAccount.tsx:195
#: src/view/com/modals/DeleteAccount.tsx:198
msgid "Password"
msgstr "비밀번호"
@@ -2766,7 +2771,7 @@ msgstr "이 앱 비밀번호에 대해 고유한 이름을 입력하거나 무
msgid "Please enter your email."
msgstr "이메일을 입력하세요."
#: src/view/com/modals/DeleteAccount.tsx:184
#: src/view/com/modals/DeleteAccount.tsx:187
msgid "Please enter your password as well:"
msgstr "비밀번호도 입력해 주세요:"
@@ -3683,8 +3688,8 @@ msgid "Subscribe to this list"
msgstr "이 리스트로 구독"
#: src/view/com/lists/ListCard.tsx:101
msgid "Subscribed"
msgstr ""
#~ msgid "Subscribed"
#~ msgstr ""
#: src/view/screens/Search/Search.tsx:362
msgid "Suggested Follows"
@@ -3839,7 +3844,7 @@ msgstr "서버에 연결하는 동안 문제가 발생했습니다."
msgid "There was an issue fetching notifications. Tap here to try again."
msgstr ""
#: src/view/com/posts/Feed.tsx:261
#: src/view/com/posts/Feed.tsx:263
msgid "There was an issue fetching posts. Tap here to try again."
msgstr ""
@@ -4029,16 +4034,16 @@ msgstr "리스트 언뮤트"
msgid "Unable to contact your service. Please check your Internet connection."
msgstr "서비스에 연결할 수 없습니다. 인터넷 연결을 확인하세요."
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:472
#: src/view/screens/ProfileList.tsx:568
msgid "Unblock"
msgstr "차단 해제"
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:308
#: src/view/com/profile/ProfileHeader.tsx:392
msgid "Unblock Account"
@@ -4290,6 +4295,10 @@ msgstr "사이트 방문"
msgid "Warn"
msgstr "경고"
#: src/view/com/posts/DiscoverFallbackHeader.tsx:29
msgid "We ran out of posts from your follows. Here's the latest from"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:48
msgid "We'll look into your appeal promptly."
msgstr "이의신청을 즉시 검토하겠습니다."
+48 -39
View File
@@ -373,11 +373,6 @@ msgstr "Nudez artística ou não erótica."
#~ msgid "Ask apps to limit the visibility of my account"
#~ msgstr ""
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:141
#: src/view/com/auth/login/ChooseAccountForm.tsx:151
#: src/view/com/auth/login/ForgotPasswordForm.tsx:170
@@ -392,6 +387,11 @@ msgstr ""
msgid "Back"
msgstr "Voltar"
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/screens/Settings.tsx:489
msgid "Basics"
msgstr "Básicos"
@@ -426,6 +426,7 @@ msgstr "Bloquear esta conta?"
msgid "Block this List"
msgstr ""
#: src/view/com/lists/ListCard.tsx:109
#: src/view/com/util/post-embeds/QuoteEmbed.tsx:57
msgid "Blocked"
msgstr ""
@@ -528,15 +529,6 @@ msgstr "Câmera"
msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long."
msgstr "Só pode conter letras, números, espaços, traços e sublinhados. Deve ter pelo menos 4 caracteres, mas não mais de 32 caracteres."
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:227
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/composer/Composer.tsx:300
#: src/view/com/composer/Composer.tsx:305
#: src/view/com/modals/ChangeEmail.tsx:218
@@ -555,8 +547,17 @@ msgstr ""
msgid "Cancel"
msgstr "Cancelar"
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:230
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:148
#: src/view/com/modals/DeleteAccount.tsx:223
#: src/view/com/modals/DeleteAccount.tsx:226
msgid "Cancel account deletion"
msgstr "Cancelar exclusão da conta"
@@ -736,12 +737,6 @@ msgstr ""
msgid "Compose reply"
msgstr "Escrever resposta"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:98
#: src/view/com/modals/SelfLabel.tsx:154
#: src/view/com/modals/VerifyEmail.tsx:231
@@ -751,6 +746,12 @@ msgstr ""
msgid "Confirm"
msgstr "Confirme"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/ChangeEmail.tsx:193
#: src/view/com/modals/ChangeEmail.tsx:195
msgid "Confirm Change"
@@ -760,7 +761,7 @@ msgstr "Confirmar Alterações"
msgid "Confirm content language settings"
msgstr "Confirmar configurações de idioma de conteúdo"
#: src/view/com/modals/DeleteAccount.tsx:213
#: src/view/com/modals/DeleteAccount.tsx:216
msgid "Confirm delete account"
msgstr "Confirmar a exclusão da conta"
@@ -952,7 +953,7 @@ msgstr "Excluir senha do aplicativo"
msgid "Delete List"
msgstr "Excluir Lista"
#: src/view/com/modals/DeleteAccount.tsx:216
#: src/view/com/modals/DeleteAccount.tsx:219
msgid "Delete my account"
msgstr "Excluir minha conta"
@@ -1673,7 +1674,7 @@ msgstr ""
msgid "Input new password"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:196
#: src/view/com/modals/DeleteAccount.tsx:199
msgid "Input password for account deletion"
msgstr ""
@@ -2075,6 +2076,10 @@ msgstr ""
msgid "Mute thread"
msgstr "Silenciar tópico"
#: src/view/com/lists/ListCard.tsx:101
msgid "Muted"
msgstr ""
#: src/view/screens/Moderation.tsx:109
msgid "Muted accounts"
msgstr "Contas silenciadas"
@@ -2189,11 +2194,6 @@ msgstr ""
msgid "Newest replies first"
msgstr "Respostas mais recentes primeiro"
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:154
#: src/view/com/auth/login/ForgotPasswordForm.tsx:178
#: src/view/com/auth/login/ForgotPasswordForm.tsx:188
@@ -2204,6 +2204,11 @@ msgstr ""
msgid "Next"
msgstr "Próximo"
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:142
msgid "Next image"
msgstr "Próxima imagem"
@@ -2477,7 +2482,7 @@ msgstr "Página não encontrada"
#: src/view/com/auth/create/Step2.tsx:132
#: src/view/com/auth/login/LoginForm.tsx:223
#: src/view/com/auth/login/SetNewPasswordForm.tsx:132
#: src/view/com/modals/DeleteAccount.tsx:195
#: src/view/com/modals/DeleteAccount.tsx:198
msgid "Password"
msgstr "Senha"
@@ -2555,7 +2560,7 @@ msgstr "Por favor, insira um nome único para esta Senha do Aplicativo ou use no
msgid "Please enter your email."
msgstr "Por favor, digite o seu email."
#: src/view/com/modals/DeleteAccount.tsx:184
#: src/view/com/modals/DeleteAccount.tsx:187
msgid "Please enter your password as well:"
msgstr "Por favor, digite sua senha também:"
@@ -3422,8 +3427,8 @@ msgid "Subscribe to this list"
msgstr "Assinar esta lista"
#: src/view/com/lists/ListCard.tsx:101
msgid "Subscribed"
msgstr ""
#~ msgid "Subscribed"
#~ msgstr ""
#: src/view/screens/Search/Search.tsx:362
msgid "Suggested Follows"
@@ -3557,7 +3562,7 @@ msgstr ""
msgid "There was an issue fetching notifications. Tap here to try again."
msgstr ""
#: src/view/com/posts/Feed.tsx:261
#: src/view/com/posts/Feed.tsx:263
msgid "There was an issue fetching posts. Tap here to try again."
msgstr ""
@@ -3735,16 +3740,16 @@ msgstr "Lista de não silenciados"
msgid "Unable to contact your service. Please check your Internet connection."
msgstr "Não foi possível entrar em contato com seu serviço. Por favor, verifique sua conexão à internet."
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:472
#: src/view/screens/ProfileList.tsx:568
msgid "Unblock"
msgstr "Desbloquear"
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:308
#: src/view/com/profile/ProfileHeader.tsx:392
msgid "Unblock Account"
@@ -3958,6 +3963,10 @@ msgstr "Visitar Site"
msgid "Warn"
msgstr ""
#: src/view/com/posts/DiscoverFallbackHeader.tsx:29
msgid "We ran out of posts from your follows. Here's the latest from"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:48
msgid "We'll look into your appeal promptly."
msgstr ""
+48 -39
View File
@@ -378,11 +378,6 @@ msgstr "Художня або нееротична оголеність."
#~ msgid "Ask apps to limit the visibility of my account"
#~ msgstr ""
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:141
#: src/view/com/auth/login/ChooseAccountForm.tsx:151
#: src/view/com/auth/login/ForgotPasswordForm.tsx:170
@@ -397,6 +392,11 @@ msgstr ""
msgid "Back"
msgstr "Назад"
#: src/view/com/post-thread/PostThread.tsx:400
msgctxt "action"
msgid "Back"
msgstr ""
#: src/view/screens/Settings.tsx:489
msgid "Basics"
msgstr "Основні"
@@ -431,6 +431,7 @@ msgstr "Заблокувати ці облікові записи?"
msgid "Block this List"
msgstr ""
#: src/view/com/lists/ListCard.tsx:109
#: src/view/com/util/post-embeds/QuoteEmbed.tsx:57
msgid "Blocked"
msgstr ""
@@ -533,15 +534,6 @@ msgstr "Камера"
msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long."
msgstr "Може містити лише літери, цифри, пробіли, дефіси та знаки підкреслення, і мати довжину від 4 до 32 символів."
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:227
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/composer/Composer.tsx:300
#: src/view/com/composer/Composer.tsx:305
#: src/view/com/modals/ChangeEmail.tsx:218
@@ -560,8 +552,17 @@ msgstr ""
msgid "Cancel"
msgstr "Скасувати"
#: src/view/com/modals/Confirm.tsx:88
#: src/view/com/modals/Confirm.tsx:91
#: src/view/com/modals/CreateOrEditList.tsx:293
#: src/view/com/modals/DeleteAccount.tsx:152
#: src/view/com/modals/DeleteAccount.tsx:230
msgctxt "action"
msgid "Cancel"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:148
#: src/view/com/modals/DeleteAccount.tsx:223
#: src/view/com/modals/DeleteAccount.tsx:226
msgid "Cancel account deletion"
msgstr "Скасувати видалення облікового запису"
@@ -741,12 +742,6 @@ msgstr ""
msgid "Compose reply"
msgstr "Відповісти"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:98
#: src/view/com/modals/SelfLabel.tsx:154
#: src/view/com/modals/VerifyEmail.tsx:231
@@ -756,6 +751,12 @@ msgstr ""
msgid "Confirm"
msgstr "Підтвердити"
#: src/view/com/modals/Confirm.tsx:75
#: src/view/com/modals/Confirm.tsx:78
msgctxt "action"
msgid "Confirm"
msgstr ""
#: src/view/com/modals/ChangeEmail.tsx:193
#: src/view/com/modals/ChangeEmail.tsx:195
msgid "Confirm Change"
@@ -765,7 +766,7 @@ msgstr "Підтвердити"
msgid "Confirm content language settings"
msgstr "Підтвердити перелік мов"
#: src/view/com/modals/DeleteAccount.tsx:213
#: src/view/com/modals/DeleteAccount.tsx:216
msgid "Confirm delete account"
msgstr "Підтвердити видалення облікового запису"
@@ -957,7 +958,7 @@ msgstr "Видалити пароль для застосунку"
msgid "Delete List"
msgstr "Видалити список"
#: src/view/com/modals/DeleteAccount.tsx:216
#: src/view/com/modals/DeleteAccount.tsx:219
msgid "Delete my account"
msgstr "Видалити мій обліковий запис"
@@ -1678,7 +1679,7 @@ msgstr ""
msgid "Input new password"
msgstr ""
#: src/view/com/modals/DeleteAccount.tsx:196
#: src/view/com/modals/DeleteAccount.tsx:199
msgid "Input password for account deletion"
msgstr ""
@@ -2080,6 +2081,10 @@ msgstr ""
msgid "Mute thread"
msgstr "Ігнорувати пост"
#: src/view/com/lists/ListCard.tsx:101
msgid "Muted"
msgstr ""
#: src/view/screens/Moderation.tsx:109
msgid "Muted accounts"
msgstr "Ігноровані облікові записи"
@@ -2194,11 +2199,6 @@ msgstr ""
msgid "Newest replies first"
msgstr "Спочатку найновіші"
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/auth/create/CreateAccount.tsx:154
#: src/view/com/auth/login/ForgotPasswordForm.tsx:178
#: src/view/com/auth/login/ForgotPasswordForm.tsx:188
@@ -2209,6 +2209,11 @@ msgstr ""
msgid "Next"
msgstr "Далі"
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103
msgctxt "action"
msgid "Next"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:142
msgid "Next image"
msgstr "Наступне зображення"
@@ -2482,7 +2487,7 @@ msgstr "Сторінку не знайдено"
#: src/view/com/auth/create/Step2.tsx:132
#: src/view/com/auth/login/LoginForm.tsx:223
#: src/view/com/auth/login/SetNewPasswordForm.tsx:132
#: src/view/com/modals/DeleteAccount.tsx:195
#: src/view/com/modals/DeleteAccount.tsx:198
msgid "Password"
msgstr "Пароль"
@@ -2560,7 +2565,7 @@ msgstr "Будь ласка, введіть унікальну назву для
msgid "Please enter your email."
msgstr "Будь ласка, введіть адресу ел. пошти."
#: src/view/com/modals/DeleteAccount.tsx:184
#: src/view/com/modals/DeleteAccount.tsx:187
msgid "Please enter your password as well:"
msgstr "Будь ласка, також введіть ваш пароль:"
@@ -3427,8 +3432,8 @@ msgid "Subscribe to this list"
msgstr "Підписатися на цей список"
#: src/view/com/lists/ListCard.tsx:101
msgid "Subscribed"
msgstr ""
#~ msgid "Subscribed"
#~ msgstr ""
#: src/view/screens/Search/Search.tsx:362
msgid "Suggested Follows"
@@ -3562,7 +3567,7 @@ msgstr ""
msgid "There was an issue fetching notifications. Tap here to try again."
msgstr ""
#: src/view/com/posts/Feed.tsx:261
#: src/view/com/posts/Feed.tsx:263
msgid "There was an issue fetching posts. Tap here to try again."
msgstr ""
@@ -3740,16 +3745,16 @@ msgstr "Перестати ігнорувати"
msgid "Unable to contact your service. Please check your Internet connection."
msgstr "Не вдалося зв'язатися з вашим хостинг-провайдером. Перевірте ваше підключення до Інтернету."
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:472
#: src/view/screens/ProfileList.tsx:568
msgid "Unblock"
msgstr "Розблокувати"
#: src/view/com/profile/ProfileHeader.tsx:475
msgctxt "action"
msgid "Unblock"
msgstr ""
#: src/view/com/profile/ProfileHeader.tsx:308
#: src/view/com/profile/ProfileHeader.tsx:392
msgid "Unblock Account"
@@ -3963,6 +3968,10 @@ msgstr "Відвідати сайт"
msgid "Warn"
msgstr ""
#: src/view/com/posts/DiscoverFallbackHeader.tsx:29
msgid "We ran out of posts from your follows. Here's the latest from"
msgstr ""
#: src/view/com/modals/AppealLabel.tsx:48
msgid "We'll look into your appeal promptly."
msgstr ""
+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>
)
}
@@ -26,7 +26,7 @@ test('migrate: fresh install', async () => {
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
expect(read).toHaveBeenCalledTimes(1)
expect(logger.log).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
'persisted state: no migration needed',
)
})
@@ -38,7 +38,7 @@ test('migrate: fresh install, existing new storage', async () => {
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
expect(read).toHaveBeenCalledTimes(1)
expect(logger.log).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
'persisted state: no migration needed',
)
})
@@ -68,7 +68,7 @@ test('migrate: has legacy data', async () => {
await migrate()
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
expect(logger.log).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
'persisted state: migrated legacy storage',
)
})
+2 -2
View File
@@ -164,14 +164,14 @@ export async function migrate() {
if (validate.success) {
await write(newData)
logger.log('persisted state: migrated legacy storage')
logger.info('persisted state: migrated legacy storage')
} else {
logger.error('persisted state: legacy data failed validation', {
error: validate.error,
})
}
} else {
logger.log('persisted state: no migration needed')
logger.info('persisted state: no migration needed')
}
} catch (e: any) {
logger.error(e, {
+2 -1
View File
@@ -272,7 +272,8 @@ export function usePinnedFeedsInfos(): {
},
})
} catch (e) {
logger.warn(`usePinnedFeedsInfos: failed to fetch ${uri}`, {
// expected failure
logger.info(`usePinnedFeedsInfos: failed to fetch ${uri}`, {
error: e,
})
}
+1 -1
View File
@@ -167,7 +167,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
broadcast.postMessage({event: unreadCountStr})
} catch (e) {
logger.error('Failed to check unread notifications', {error: e})
logger.warn('Failed to check unread notifications', {error: e})
}
},
+6 -1
View File
@@ -18,6 +18,7 @@ import {LikesFeedAPI} from 'lib/api/feed/likes'
import {CustomFeedAPI} from 'lib/api/feed/custom'
import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge'
import {HomeFeedAPI} from '#/lib/api/feed/home'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri'
@@ -338,7 +339,11 @@ function createApi(
feedTuners: FeedTunerFn[],
) {
if (feedDesc === 'home') {
return new MergeFeedAPI(params, feedTuners)
if (params.mergeFeedEnabled) {
return new MergeFeedAPI(params, feedTuners)
} else {
return new HomeFeedAPI()
}
} else if (feedDesc === 'following') {
return new FollowingFeedAPI()
} else if (feedDesc.startsWith('author')) {
+13 -1
View File
@@ -44,6 +44,8 @@ export type ApiContext = {
password: string
handle: string
inviteCode?: string
verificationPhone?: string
verificationCode?: string
}) => Promise<void>
login: (props: {
service: string
@@ -203,7 +205,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}, [setStateAndPersist, queryClient])
const createAccount = React.useCallback<ApiContext['createAccount']>(
async ({service, email, password, handle, inviteCode}: any) => {
async ({
service,
email,
password,
handle,
inviteCode,
verificationPhone,
verificationCode,
}: any) => {
logger.info(`session: creating account`, {
service,
handle,
@@ -217,6 +227,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
password,
email,
inviteCode,
verificationPhone,
verificationCode,
})
if (!agent.session) {
-204
View File
@@ -1,204 +0,0 @@
import React from 'react'
import {Pressable, Text, PressableProps, TextProps} from 'react-native'
import * as tokens from '#/alf/tokens'
import {atoms} from '#/alf'
export type ButtonType =
| 'primary'
| 'secondary'
| 'tertiary'
| 'positive'
| 'negative'
export type ButtonSize = 'small' | 'large'
export type VariantProps = {
type?: ButtonType
size?: ButtonSize
}
type ButtonState = {
pressed: boolean
hovered: boolean
focused: boolean
}
export type ButtonProps = Omit<PressableProps, 'children'> &
VariantProps & {
children:
| ((props: {
state: ButtonState
type?: ButtonType
size?: ButtonSize
}) => React.ReactNode)
| React.ReactNode
| string
}
export type ButtonTextProps = TextProps & VariantProps
export function Button({children, style, type, size, ...rest}: ButtonProps) {
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles = []
const hoverStyles = []
switch (type) {
case 'primary':
baseStyles.push({
backgroundColor: tokens.color.blue_500,
})
break
case 'secondary':
baseStyles.push({
backgroundColor: tokens.color.gray_200,
})
hoverStyles.push({
backgroundColor: tokens.color.gray_100,
})
break
default:
}
switch (size) {
case 'large':
baseStyles.push(
atoms.py_md,
atoms.px_xl,
atoms.rounded_md,
atoms.gap_sm,
)
break
case 'small':
baseStyles.push(
atoms.py_sm,
atoms.px_md,
atoms.rounded_sm,
atoms.gap_xs,
)
break
default:
}
return {
baseStyles,
hoverStyles,
}
}, [type, size])
const [state, setState] = React.useState({
pressed: false,
hovered: false,
focused: false,
})
const onPressIn = React.useCallback(() => {
setState(s => ({
...s,
pressed: true,
}))
}, [setState])
const onPressOut = React.useCallback(() => {
setState(s => ({
...s,
pressed: false,
}))
}, [setState])
const onHoverIn = React.useCallback(() => {
setState(s => ({
...s,
hovered: true,
}))
}, [setState])
const onHoverOut = React.useCallback(() => {
setState(s => ({
...s,
hovered: false,
}))
}, [setState])
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
focused: true,
}))
}, [setState])
const onBlur = React.useCallback(() => {
setState(s => ({
...s,
focused: false,
}))
}, [setState])
return (
<Pressable
{...rest}
style={state => [
atoms.flex_row,
atoms.align_center,
...baseStyles,
...(state.hovered ? hoverStyles : []),
typeof style === 'function' ? style(state) : style,
]}
onPressIn={onPressIn}
onPressOut={onPressOut}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
onFocus={onFocus}
onBlur={onBlur}>
{typeof children === 'string' ? (
<ButtonText type={type} size={size}>
{children}
</ButtonText>
) : typeof children === 'function' ? (
children({state, type, size})
) : (
children
)}
</Pressable>
)
}
export function ButtonText({
children,
style,
type,
size,
...rest
}: ButtonTextProps) {
const textStyles = React.useMemo(() => {
const base = []
switch (type) {
case 'primary':
base.push({color: tokens.color.white})
break
case 'secondary':
base.push({
color: tokens.color.gray_700,
})
break
default:
}
switch (size) {
case 'small':
base.push(atoms.text_sm, {paddingBottom: 1})
break
case 'large':
base.push(atoms.text_md, {paddingBottom: 1})
break
default:
}
return base
}, [type, size])
return (
<Text
{...rest}
style={[
atoms.flex_1,
atoms.font_semibold,
atoms.text_center,
...textStyles,
style,
]}>
{children}
</Text>
)
}
+24 -2
View File
@@ -22,12 +22,13 @@ import {
useSetSaveFeedsMutation,
DEFAULT_PROD_FEEDS,
} from '#/state/queries/preferences'
import {IS_PROD} from '#/lib/constants'
import {FEEDBACK_FORM_URL, IS_PROD} from '#/lib/constants'
import {Step1} from './Step1'
import {Step2} from './Step2'
import {Step3} from './Step3'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {TextLink} from '../../util/Link'
export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
const {screen} = useAnalytics()
@@ -117,7 +118,7 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
return (
<LoggedOutLayout
leadin={`Step ${uiState.step}`}
leadin=""
title={_(msg`Create Account`)}
description={_(msg`We're so excited to have you join us!`)}>
<ScrollView testID="createAccount" style={pal.view}>
@@ -176,6 +177,27 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
</>
) : undefined}
</View>
<View style={styles.stepContainer}>
<View
style={[
s.flexRow,
s.alignCenter,
pal.viewLight,
{borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12},
]}>
<Text type="md" style={pal.textLight}>
<Trans>Having trouble?</Trans>{' '}
</Text>
<TextLink
type="md"
style={pal.link}
text={_(msg`Contact support`)}
href={FEEDBACK_FORM_URL({email: uiState.email})}
/>
</View>
</View>
<View style={{height: isTabletOrDesktop ? 50 : 400}} />
</ScrollView>
</LoggedOutLayout>
+189 -156
View File
@@ -1,25 +1,38 @@
import React from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import {
ActivityIndicator,
Keyboard,
StyleSheet,
TouchableWithoutFeedback,
View,
} from 'react-native'
import {CreateAccountState, CreateAccountDispatch, is18} from './state'
import {Text} from 'view/com/util/text/Text'
import {DateInput} from 'view/com/util/forms/DateInput'
import {StepHeader} from './StepHeader'
import {CreateAccountState, CreateAccountDispatch} from './state'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {HelpTip} from '../util/HelpTip'
import {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput'
import {Button} from 'view/com/util/forms/Button'
import {Button} from '../../util/forms/Button'
import {Policies} from './Policies'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {msg, Trans} from '@lingui/macro'
import {isWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {logger} from '#/logger'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants'
import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
function sanitizeDate(date: Date): Date {
if (!date || date.toString() === 'Invalid Date') {
logger.error(`Create account: handled invalid date for birthDate`, {
hasDate: !!date,
})
return new Date()
}
return date
}
/** STEP 1: Your hosting provider
* @field Bluesky (default)
* @field Other (staging, local dev, your own PDS, etc.)
*/
export function Step1({
uiState,
uiDispatch,
@@ -28,136 +41,175 @@ export function Step1({
uiDispatch: CreateAccountDispatch
}) {
const pal = usePalette('default')
const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
const {_} = useLingui()
const {openModal} = useModalControls()
const onPressDefault = React.useCallback(() => {
setIsDefaultSelected(true)
uiDispatch({type: 'set-service-url', value: PROD_SERVICE})
}, [setIsDefaultSelected, uiDispatch])
const onPressSelectService = React.useCallback(() => {
openModal({
name: 'server-input',
initialService: uiState.serviceUrl,
onSelect: (url: string) =>
uiDispatch({type: 'set-service-url', value: url}),
})
Keyboard.dismiss()
}, [uiDispatch, uiState.serviceUrl, openModal])
const onPressOther = React.useCallback(() => {
setIsDefaultSelected(false)
uiDispatch({type: 'set-service-url', value: 'https://'})
}, [setIsDefaultSelected, uiDispatch])
const onPressWaitlist = React.useCallback(() => {
openModal({name: 'waitlist'})
}, [openModal])
const onChangeServiceUrl = React.useCallback(
(v: string) => {
uiDispatch({type: 'set-service-url', value: v})
},
[uiDispatch],
)
const birthDate = React.useMemo(() => {
return sanitizeDate(uiState.birthDate)
}, [uiState.birthDate])
return (
<View>
<StepHeader step="1" title={_(msg`Your hosting provider`)} />
<Text style={[pal.text, s.mb10]}>
<Trans>This is the service that keeps you online.</Trans>
</Text>
<Option
testID="blueskyServerBtn"
isSelected={isDefaultSelected}
label="Bluesky"
help="&nbsp;(default)"
onPress={onPressDefault}
/>
<Option
testID="otherServerBtn"
isSelected={!isDefaultSelected}
label="Other"
onPress={onPressOther}>
<View style={styles.otherForm}>
<Text nativeID="addressProvider" style={[pal.text, s.mb5]}>
<Trans>Enter the address of your provider:</Trans>
</Text>
<TextInput
testID="customServerInput"
icon="globe"
placeholder={_(msg`Hosting provider address`)}
value={uiState.serviceUrl}
editable
onChange={onChangeServiceUrl}
accessibilityHint={_(msg`Input hosting provider address`)}
accessibilityLabel={_(msg`Hosting provider address`)}
accessibilityLabelledBy="addressProvider"
/>
{LOGIN_INCLUDE_DEV_SERVERS && (
<View style={[s.flexRow, s.mt10]}>
<Button
testID="stagingServerBtn"
type="default"
style={s.mr5}
label={_(msg`Staging`)}
onPress={() => onChangeServiceUrl(STAGING_SERVICE)}
/>
<Button
testID="localDevServerBtn"
type="default"
label={_(msg`Dev Server`)}
onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)}
<StepHeader uiState={uiState} title={_(msg`Your account`)}>
<View>
<Button
testID="selectServiceButton"
type="default"
style={{
aspectRatio: 1,
justifyContent: 'center',
alignItems: 'center',
}}
accessibilityLabel={_(msg`Select service`)}
accessibilityHint={_(msg`Sets server for the Bluesky client`)}
onPress={onPressSelectService}>
<FontAwesomeIcon icon="server" size={21} />
</Button>
</View>
</StepHeader>
{!uiState.serviceDescription ? (
<ActivityIndicator />
) : (
<>
{uiState.isInviteCodeRequired && (
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
<Trans>Invite code</Trans>
</Text>
<TextInput
testID="inviteCodeInput"
icon="ticket"
placeholder={_(msg`Required for this provider`)}
value={uiState.inviteCode}
editable
onChange={value => uiDispatch({type: 'set-invite-code', value})}
accessibilityLabel={_(msg`Invite code`)}
accessibilityHint={_(msg`Input invite code to proceed`)}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
autoFocus={true}
/>
</View>
)}
</View>
</Option>
{!uiState.inviteCode && uiState.isInviteCodeRequired ? (
<View style={[s.flexRow, s.alignCenter]}>
<Text style={pal.text}>
<Trans>Don't have an invite code?</Trans>{' '}
</Text>
<TouchableWithoutFeedback
onPress={onPressWaitlist}
accessibilityLabel={_(msg`Join the waitlist.`)}
accessibilityHint="">
<View style={styles.touchable}>
<Text style={pal.link}>
<Trans>Join the waitlist.</Trans>
</Text>
</View>
</TouchableWithoutFeedback>
</View>
) : (
<>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="email">
<Trans>Email address</Trans>
</Text>
<TextInput
testID="emailInput"
icon="envelope"
placeholder={_(msg`Enter your email address`)}
value={uiState.email}
editable
onChange={value => uiDispatch({type: 'set-email', value})}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(msg`Input email for Bluesky account`)}
accessibilityLabelledBy="email"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
autoFocus={!uiState.isInviteCodeRequired}
/>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="password">
<Trans>Password</Trans>
</Text>
<TextInput
testID="passwordInput"
icon="lock"
placeholder={_(msg`Choose your password`)}
value={uiState.password}
editable
secureTextEntry
onChange={value => uiDispatch({type: 'set-password', value})}
accessibilityLabel={_(msg`Password`)}
accessibilityHint={_(msg`Set password`)}
accessibilityLabelledBy="password"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="birthDate">
<Trans>Your birth date</Trans>
</Text>
<DateInput
handleAsUTC
testID="birthdayInput"
value={birthDate}
onChange={value =>
uiDispatch({type: 'set-birth-date', value})
}
buttonType="default-light"
buttonStyle={[pal.border, styles.dateInputButton]}
buttonLabelType="lg"
accessibilityLabel={_(msg`Birthday`)}
accessibilityHint={_(msg`Enter your birth date`)}
accessibilityLabelledBy="birthDate"
/>
</View>
{uiState.serviceDescription && (
<Policies
serviceDescription={uiState.serviceDescription}
needsGuardian={!is18(uiState)}
/>
)}
</>
)}
</>
)}
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : (
<HelpTip text={_(msg`You can change hosting providers at any time.`)} />
)}
</View>
)
}
function Option({
children,
isSelected,
label,
help,
onPress,
testID,
}: React.PropsWithChildren<{
isSelected: boolean
label: string
help?: string
onPress: () => void
testID?: string
}>) {
const theme = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
const circleFillStyle = React.useMemo(
() => ({
backgroundColor: theme.palette.primary.background,
}),
[theme],
)
return (
<View style={[styles.option, pal.border]}>
<TouchableWithoutFeedback
onPress={onPress}
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint={_(msg`Sets hosting provider to ${label}`)}>
<View style={styles.optionHeading}>
<View style={[styles.circle, pal.border]}>
{isSelected ? (
<View style={[circleFillStyle, styles.circleFill]} />
) : undefined}
</View>
<Text type="xl" style={pal.text}>
{label}
{help ? (
<Text type="xl" style={pal.textLight}>
{help}
</Text>
) : undefined}
</Text>
</View>
</TouchableWithoutFeedback>
{isSelected && children}
) : undefined}
</View>
)
}
@@ -165,34 +217,15 @@ function Option({
const styles = StyleSheet.create({
error: {
borderRadius: 6,
marginTop: 10,
},
option: {
dateInputButton: {
borderWidth: 1,
borderRadius: 6,
marginBottom: 10,
paddingVertical: 14,
},
optionHeading: {
flexDirection: 'row',
alignItems: 'center',
padding: 10,
},
circle: {
width: 26,
height: 26,
borderRadius: 15,
padding: 4,
borderWidth: 1,
marginRight: 10,
},
circleFill: {
width: 16,
height: 16,
borderRadius: 10,
},
otherForm: {
paddingBottom: 10,
paddingHorizontal: 12,
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
touchable: {
...(isWeb && {cursor: 'pointer'}),
},
})
+143 -134
View File
@@ -1,39 +1,28 @@
import React from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import {CreateAccountState, CreateAccountDispatch, is18} from './state'
import {
ActivityIndicator,
StyleSheet,
TouchableWithoutFeedback,
View,
} from 'react-native'
import {
CreateAccountState,
CreateAccountDispatch,
requestVerificationCode,
} from './state'
import {Text} from 'view/com/util/text/Text'
import {DateInput} from 'view/com/util/forms/DateInput'
import {StepHeader} from './StepHeader'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput'
import {Policies} from './Policies'
import {Button} from '../../util/forms/Button'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {isWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {logger} from '#/logger'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import parsePhoneNumber from 'libphonenumber-js'
function sanitizeDate(date: Date): Date {
if (!date || date.toString() === 'Invalid Date') {
logger.error(`Create account: handled invalid date for birthDate`, {
hasDate: !!date,
})
return new Date()
}
return date
}
/** STEP 2: Your account
* @field Invite code or waitlist
* @field Email address
* @field Email address
* @field Email address
* @field Password
* @field Birth date
* @readonly Terms of service & privacy policy
*/
export function Step2({
uiState,
uiDispatch,
@@ -43,130 +32,155 @@ export function Step2({
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {openModal} = useModalControls()
const {isMobile} = useWebMediaQueries()
const onPressWaitlist = React.useCallback(() => {
openModal({name: 'waitlist'})
}, [openModal])
const onPressRequest = React.useCallback(() => {
if (
uiState.verificationPhone.length >= 9 &&
parsePhoneNumber(uiState.verificationPhone, 'US')
) {
requestVerificationCode({uiState, uiDispatch, _})
} else {
uiDispatch({
type: 'set-error',
value: _(
msg`There's something wrong with this number. Please include your country and/or area code!`,
),
})
}
}, [uiState, uiDispatch, _])
const birthDate = React.useMemo(() => {
return sanitizeDate(uiState.birthDate)
}, [uiState.birthDate])
const onPressRetry = React.useCallback(() => {
uiDispatch({type: 'set-has-requested-verification-code', value: false})
}, [uiDispatch])
const phoneNumberFormatted = React.useMemo(
() =>
uiState.hasRequestedVerificationCode
? parsePhoneNumber(
uiState.verificationPhone,
'US',
)?.formatInternational()
: '',
[uiState.hasRequestedVerificationCode, uiState.verificationPhone],
)
return (
<View>
<StepHeader step="2" title={_(msg`Your account`)} />
<StepHeader uiState={uiState} title={_(msg`SMS verification`)} />
{uiState.isInviteCodeRequired && (
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
<Trans>Invite code</Trans>
</Text>
<TextInput
testID="inviteCodeInput"
icon="ticket"
placeholder={_(msg`Required for this provider`)}
value={uiState.inviteCode}
editable
onChange={value => uiDispatch({type: 'set-invite-code', value})}
accessibilityLabel={_(msg`Invite code`)}
accessibilityHint={_(msg`Input invite code to proceed`)}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
)}
{!uiState.hasRequestedVerificationCode ? (
<>
<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>
{!uiState.inviteCode && uiState.isInviteCodeRequired ? (
<Text style={[s.alignBaseline, pal.text]}>
<Trans>Don't have an invite code?</Trans>{' '}
<TouchableWithoutFeedback
onPress={onPressWaitlist}
accessibilityLabel={_(msg`Join the waitlist.`)}
accessibilityHint="">
<View style={styles.touchable}>
<Text style={pal.link}>
<Trans>Join the waitlist.</Trans>
</Text>
</View>
</TouchableWithoutFeedback>
</Text>
<View style={isMobile ? {} : {flexDirection: 'row'}}>
{uiState.isProcessing ? (
<ActivityIndicator />
) : (
<Button
testID="requestCodeBtn"
type="primary"
label={_(msg`Request code`)}
labelStyle={isMobile ? [s.flex1, s.textCenter, s.f17] : []}
style={
isMobile ? {paddingVertical: 12, paddingHorizontal: 20} : {}
}
onPress={onPressRequest}
/>
)}
</View>
</>
) : (
<>
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]} nativeID="email">
<Trans>Email address</Trans>
</Text>
<View
style={[
s.flexRow,
s.mb5,
s.alignCenter,
{justifyContent: 'space-between'},
]}>
<Text
type="md-medium"
style={pal.text}
nativeID="verificationCode">
<Trans>Verification code</Trans>{' '}
</Text>
<TouchableWithoutFeedback
onPress={onPressRetry}
accessibilityLabel={_(msg`Retry.`)}
accessibilityHint="">
<View style={styles.touchable}>
<Text
type="md-medium"
style={pal.link}
nativeID="verificationCode">
<Trans>Retry</Trans>
</Text>
</View>
</TouchableWithoutFeedback>
</View>
<TextInput
testID="emailInput"
icon="envelope"
placeholder={_(msg`Enter your email address`)}
value={uiState.email}
testID="codeInput"
icon="hashtag"
placeholder={_(msg`XXXXXX`)}
value={uiState.verificationCode}
editable
onChange={value => uiDispatch({type: 'set-email', value})}
onChange={value =>
uiDispatch({type: 'set-verification-code', value})
}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(msg`Input email for Bluesky waitlist`)}
accessibilityLabelledBy="email"
accessibilityHint={_(
msg`Input the verification code we have texted to you`,
)}
accessibilityLabelledBy="verificationCode"
keyboardType="phone-pad"
autoCapitalize="none"
autoComplete="off"
autoComplete="one-time-code"
textContentType="oneTimeCode"
autoCorrect={false}
autoFocus={true}
/>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="password">
<Trans>Password</Trans>
<Text type="sm" style={[pal.textLight, s.mt5]}>
<Trans>Please enter the verification code sent to</Trans>{' '}
{phoneNumberFormatted}.
</Text>
<TextInput
testID="passwordInput"
icon="lock"
placeholder={_(msg`Choose your password`)}
value={uiState.password}
editable
secureTextEntry
onChange={value => uiDispatch({type: 'set-password', value})}
accessibilityLabel={_(msg`Password`)}
accessibilityHint={_(msg`Set password`)}
accessibilityLabelledBy="password"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="birthDate">
<Trans>Your birth date</Trans>
</Text>
<DateInput
handleAsUTC
testID="birthdayInput"
value={birthDate}
onChange={value => uiDispatch({type: 'set-birth-date', value})}
buttonType="default-light"
buttonStyle={[pal.border, styles.dateInputButton]}
buttonLabelType="lg"
accessibilityLabel={_(msg`Birthday`)}
accessibilityHint={_(msg`Enter your birth date`)}
accessibilityLabelledBy="birthDate"
/>
</View>
{uiState.serviceDescription && (
<Policies
serviceDescription={uiState.serviceDescription}
needsGuardian={!is18(uiState)}
/>
)}
</>
)}
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
@@ -179,11 +193,6 @@ const styles = StyleSheet.create({
borderRadius: 6,
marginTop: 10,
},
dateInputButton: {
borderWidth: 1,
borderRadius: 6,
paddingVertical: 14,
},
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
touchable: {
...(isWeb && {cursor: 'pointer'}),
+1 -1
View File
@@ -25,7 +25,7 @@ export function Step3({
const {_} = useLingui()
return (
<View>
<StepHeader step="3" title={_(msg`Your user handle`)} />
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
<View style={s.pb10}>
<TextInput
testID="handleInput"
+26 -11
View File
@@ -3,27 +3,42 @@ import {StyleSheet, View} from 'react-native'
import {Text} from 'view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {Trans} from '@lingui/macro'
import {CreateAccountState} from './state'
export function StepHeader({step, title}: {step: string; title: string}) {
export function StepHeader({
uiState,
title,
children,
}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) {
const pal = usePalette('default')
const numSteps = uiState.isPhoneVerificationRequired ? 3 : 2
return (
<View style={styles.container}>
<Text type="lg" style={[pal.textLight]}>
{step === '3' ? (
<Trans>Last step!</Trans>
) : (
<Trans>Step {step} of 3</Trans>
)}
</Text>
<Text style={[pal.text]} type="title-xl">
{title}
</Text>
<View>
<Text type="lg" style={[pal.textLight]}>
{uiState.step === 3 ? (
<Trans>Last step!</Trans>
) : (
<Trans>
Step {uiState.step} of {numSteps}
</Trans>
)}
</Text>
<Text style={[pal.text]} type="title-xl">
{title}
</Text>
</View>
{children}
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
})
+99 -8
View File
@@ -2,6 +2,7 @@ import {useReducer} from 'react'
import {
ComAtprotoServerDescribeServer,
ComAtprotoServerCreateAccount,
BskyAgent,
} from '@atproto/api'
import {I18nContext, useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
@@ -13,6 +14,7 @@ import {cleanError} from '#/lib/strings/errors'
import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
import {ApiContext as SessionApiContext} from '#/state/session'
import {DEFAULT_SERVICE} from '#/lib/constants'
import parsePhoneNumber from 'libphonenumber-js'
export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
@@ -27,6 +29,9 @@ export type CreateAccountAction =
| {type: 'set-invite-code'; value: string}
| {type: 'set-email'; value: string}
| {type: 'set-password'; value: string}
| {type: 'set-verification-phone'; value: string}
| {type: 'set-verification-code'; value: string}
| {type: 'set-has-requested-verification-code'; value: boolean}
| {type: 'set-handle'; value: string}
| {type: 'set-birth-date'; value: Date}
| {type: 'next'}
@@ -43,6 +48,9 @@ export interface CreateAccountState {
inviteCode: string
email: string
password: string
verificationPhone: string
verificationCode: string
hasRequestedVerificationCode: boolean
handle: string
birthDate: Date
@@ -50,6 +58,7 @@ export interface CreateAccountState {
canBack: boolean
canNext: boolean
isInviteCodeRequired: boolean
isPhoneVerificationRequired: boolean
}
export type CreateAccountDispatch = (action: CreateAccountAction) => void
@@ -66,15 +75,51 @@ export function useCreateAccount() {
inviteCode: '',
email: '',
password: '',
verificationPhone: '',
verificationCode: '',
hasRequestedVerificationCode: false,
handle: '',
birthDate: DEFAULT_DATE,
canBack: false,
canNext: false,
isInviteCodeRequired: false,
isPhoneVerificationRequired: false,
})
}
export async function requestVerificationCode({
uiState,
uiDispatch,
_,
}: {
uiState: CreateAccountState
uiDispatch: CreateAccountDispatch
_: I18nContext['_']
}) {
const phoneNumber = parsePhoneNumber(uiState.verificationPhone, 'US')?.number
if (!phoneNumber) {
return
}
uiDispatch({type: 'set-error', value: ''})
uiDispatch({type: 'set-processing', value: true})
uiDispatch({type: 'set-verification-phone', value: phoneNumber})
try {
const agent = new BskyAgent({service: uiState.serviceUrl})
await agent.com.atproto.temp.requestPhoneVerification({
phoneNumber,
})
uiDispatch({type: 'set-has-requested-verification-code', value: true})
} catch (e: any) {
logger.error(
`Failed to request sms verification code (${e.status} status)`,
{error: e},
)
uiDispatch({type: 'set-error', value: cleanError(e.toString())})
}
uiDispatch({type: 'set-processing', value: false})
}
export async function submit({
createAccount,
onboardingDispatch,
@@ -89,26 +134,36 @@ export async function submit({
_: I18nContext['_']
}) {
if (!uiState.email) {
uiDispatch({type: 'set-step', value: 2})
uiDispatch({type: 'set-step', value: 1})
return uiDispatch({
type: 'set-error',
value: _(msg`Please enter your email.`),
})
}
if (!EmailValidator.validate(uiState.email)) {
uiDispatch({type: 'set-step', value: 2})
uiDispatch({type: 'set-step', value: 1})
return uiDispatch({
type: 'set-error',
value: _(msg`Your email appears to be invalid.`),
})
}
if (!uiState.password) {
uiDispatch({type: 'set-step', value: 2})
uiDispatch({type: 'set-step', value: 1})
return uiDispatch({
type: 'set-error',
value: _(msg`Please choose your password.`),
})
}
if (
uiState.isPhoneVerificationRequired &&
(!uiState.verificationPhone || !uiState.verificationCode)
) {
uiDispatch({type: 'set-step', value: 2})
return uiDispatch({
type: 'set-error',
value: _(msg`Please enter the code you received by SMS.`),
})
}
if (!uiState.handle) {
uiDispatch({type: 'set-step', value: 3})
return uiDispatch({
@@ -127,6 +182,8 @@ export async function submit({
handle: createFullHandle(uiState.handle, uiState.userDomain),
password: uiState.password,
inviteCode: uiState.inviteCode.trim(),
verificationPhone: uiState.verificationPhone.trim(),
verificationCode: uiState.verificationCode.trim(),
})
} catch (e: any) {
onboardingDispatch({type: 'skip'}) // undo starting the onboard
@@ -135,6 +192,9 @@ export async function submit({
errMsg = _(
msg`Invite code not accepted. Check that you input it correctly and try again.`,
)
uiDispatch({type: 'set-step', value: 1})
} else if (e.error === 'InvalidPhoneVerification') {
uiDispatch({type: 'set-step', value: 2})
}
if ([400, 429].includes(e.status)) {
@@ -201,6 +261,19 @@ function createReducer({_}: {_: I18nContext['_']}) {
case 'set-password': {
return compute({...state, password: action.value})
}
case 'set-verification-phone': {
return compute({
...state,
verificationPhone: action.value,
hasRequestedVerificationCode: false,
})
}
case 'set-verification-code': {
return compute({...state, verificationCode: action.value.trim()})
}
case 'set-has-requested-verification-code': {
return compute({...state, hasRequestedVerificationCode: action.value})
}
case 'set-handle': {
return compute({...state, handle: action.value})
}
@@ -208,7 +281,7 @@ function createReducer({_}: {_: I18nContext['_']}) {
return compute({...state, birthDate: action.value})
}
case 'next': {
if (state.step === 2) {
if (state.step === 1) {
if (!is13(state)) {
return compute({
...state,
@@ -218,10 +291,18 @@ function createReducer({_}: {_: I18nContext['_']}) {
})
}
}
return compute({...state, error: '', step: state.step + 1})
let increment = 1
if (state.step === 1 && !state.isPhoneVerificationRequired) {
increment = 2
}
return compute({...state, error: '', step: state.step + increment})
}
case 'back': {
return compute({...state, error: '', step: state.step - 1})
let decrement = 1
if (state.step === 3 && !state.isPhoneVerificationRequired) {
decrement = 2
}
return compute({...state, error: '', step: state.step - decrement})
}
}
}
@@ -230,12 +311,16 @@ function createReducer({_}: {_: I18nContext['_']}) {
function compute(state: CreateAccountState): CreateAccountState {
let canNext = true
if (state.step === 1) {
canNext = !!state.serviceDescription
} else if (state.step === 2) {
canNext =
!!state.serviceDescription &&
(!state.isInviteCodeRequired || !!state.inviteCode) &&
!!state.email &&
!!state.password
} else if (state.step === 2) {
canNext =
!state.isPhoneVerificationRequired ||
(!!state.verificationPhone &&
isValidVerificationCode(state.verificationCode))
} else if (state.step === 3) {
canNext = !!state.handle
}
@@ -244,5 +329,11 @@ function compute(state: CreateAccountState): CreateAccountState {
canBack: state.step > 1,
canNext,
isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
isPhoneVerificationRequired:
!!state.serviceDescription?.phoneVerificationRequired,
}
}
function isValidVerificationCode(str: string): boolean {
return /[0-9]{6}/.test(str)
}
+3 -3
View File
@@ -74,7 +74,7 @@ export const ComposePost = observer(function ComposePost({
}: Props) {
const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
const {activeModals} = useModals()
const {isModalActive, activeModals} = useModals()
const {openModal, closeModal} = useModalControls()
const {closeComposer} = useComposerControls()
const {track} = useAnalytics()
@@ -176,11 +176,11 @@ export const ComposePost = observer(function ComposePost({
[onPressCancel],
)
useEffect(() => {
if (isWeb) {
if (isWeb && !isModalActive) {
window.addEventListener('keydown', onEscape)
return () => window.removeEventListener('keydown', onEscape)
}
}, [onEscape])
}, [onEscape, isModalActive])
const onPressAddLinkCard = useCallback(
(uri: string) => {
+13 -5
View File
@@ -94,15 +94,23 @@ export const ListCard = ({
</Trans>
))}
</Text>
{!!list.viewer?.muted && (
<View style={s.flexRow}>
<View style={s.flexRow}>
{list.viewer?.muted ? (
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
<Trans>Subscribed</Trans>
<Trans>Muted</Trans>
</Text>
</View>
</View>
)}
) : null}
{list.viewer?.blocked ? (
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
<Trans>Blocked</Trans>
</Text>
</View>
) : null}
</View>
</View>
{renderButton ? (
<View style={styles.layoutButton}>{renderButton()}</View>
+10 -12
View File
@@ -182,19 +182,17 @@ export function Component({
]}
testID="createOrEditListModal">
<Text style={[styles.title, pal.text]}>
<Trans>
{isCurateList ? (
list ? (
<Trans>Edit User List</Trans>
) : (
<Trans>New User List</Trans>
)
) : list ? (
<Trans>Edit Moderation List</Trans>
{isCurateList ? (
list ? (
<Trans>Edit User List</Trans>
) : (
<Trans>New Moderation List</Trans>
)}
</Trans>
<Trans>New User List</Trans>
)
) : list ? (
<Trans>Edit Moderation List</Trans>
) : (
<Trans>New Moderation List</Trans>
)}
</Text>
{error !== '' && (
<View style={styles.errorContainer}>
+5 -2
View File
@@ -160,7 +160,7 @@ export function Component({}: {}) {
{/* TODO: Update this label to be more concise */}
<Text
type="lg"
style={styles.description}
style={[pal.text, styles.description]}
nativeID="confirmationCode">
<Trans>
Check your inbox for an email with the confirmation code to
@@ -180,7 +180,10 @@ export function Component({}: {}) {
msg`Input confirmation code for account deletion`,
)}
/>
<Text type="lg" style={styles.description} nativeID="password">
<Text
type="lg"
style={[pal.text, styles.description]}
nativeID="password">
<Trans>Please enter your password as well:</Trans>
</Text>
<TextInput
+5 -1
View File
@@ -63,7 +63,11 @@ function Modal({modal}: {modal: ModalIface}) {
}
const onPressMask = () => {
if (modal.name === 'crop-image' || modal.name === 'edit-image') {
if (
modal.name === 'crop-image' ||
modal.name === 'edit-image' ||
modal.name === 'alt-text-image'
) {
return // dont close on mask presses during crop
}
closeModal()
+21 -2
View File
@@ -20,6 +20,11 @@ import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {Logo} from '#/view/icons/Logo'
import {IS_DEV} from '#/env'
import {atoms} from '#/alf'
import {Link as Link2} from '#/components/Link'
import {ColorPalette_Stroke2_Corner0_Rounded as ColorPalette} from '#/components/icons/ColorPalette'
export function FeedsTabBar(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
@@ -68,7 +73,7 @@ export function FeedsTabBar(
headerHeight.value = e.nativeEvent.layout.height
}}>
<View style={[pal.view, styles.topBar]}>
<View style={[pal.view]}>
<View style={[pal.view, {width: 100}]}>
<TouchableOpacity
testID="viewHeaderDrawerBtn"
onPress={onPressAvi}
@@ -88,7 +93,21 @@ export function FeedsTabBar(
<View>
<Logo width={30} />
</View>
<View style={[pal.view, {width: 18}]}>
<View
style={[
atoms.flex_row,
atoms.justify_end,
atoms.align_center,
atoms.gap_md,
pal.view,
{width: 100},
]}>
{IS_DEV && (
<Link2 to="/sys/debug">
<ColorPalette size="md" />
</Link2>
)}
{hasSession && (
<Link
testID="viewHeaderHomeFeedPrefsBtn"
@@ -0,0 +1,43 @@
import React from 'react'
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {Text} from '../util/text/Text'
import {usePalette} from '#/lib/hooks/usePalette'
import {TextLink} from '../util/Link'
import {InfoCircleIcon} from '#/lib/icons'
export function DiscoverFallbackHeader() {
const pal = usePalette('default')
return (
<View
style={[
{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 12,
borderTopWidth: 1,
},
pal.border,
pal.viewLight,
]}>
<View style={{width: 68, paddingLeft: 12}}>
<InfoCircleIcon size={36} style={pal.textLight} strokeWidth={1.5} />
</View>
<View style={{flex: 1}}>
<Text type="md" style={pal.text}>
<Trans>
We ran out of posts from your follows. Here's the latest from{' '}
<TextLink
type="md-medium"
href="/profile/bsky.app/feed/whats-hot"
text="Discover"
style={pal.link}
/>
.
</Trans>
</Text>
</View>
</View>
)
}
+8
View File
@@ -30,6 +30,8 @@ import {useSession} from '#/state/session'
import {STALE} from '#/state/queries'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home'
const LOADING_ITEM = {_reactKey: '__loading__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
@@ -265,6 +267,12 @@ let Feed = ({
)
} else if (item === LOADING_ITEM) {
return <PostFeedLoadingPlaceholder />
} else if (item.rootUri === FALLBACK_MARKER_POST.post.uri) {
// HACK
// tell the user we fell back to discover
// see home.ts (feed api) for more info
// -prf
return <DiscoverFallbackHeader />
}
return <FeedSlice slice={item} />
},
+8 -1
View File
@@ -306,6 +306,8 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
)
})
const EXEMPT_PATHS = ['/robots.txt', '/security.txt', '/.well-known/']
// NOTE
// we can't use the onPress given by useLinkProps because it will
// match most paths to the HomeTab routes while we actually want to
@@ -350,7 +352,12 @@ function onPressInner(
if (shouldHandle) {
href = convertBskyAppUrlIfNeeded(href)
if (newTab || href.startsWith('http') || href.startsWith('mailto')) {
if (
newTab ||
href.startsWith('http') ||
href.startsWith('mailto') ||
EXEMPT_PATHS.some(path => href.startsWith(path))
) {
openLink(href)
} else {
closeModal() // close any active modals
+2 -15
View File
@@ -22,7 +22,6 @@ import {Link} from '../Link'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
import {AutoSizedImage} from '../images/AutoSizedImage'
@@ -51,7 +50,6 @@ export function PostEmbeds({
}) {
const pal = usePalette('default')
const {openLightbox} = useLightboxControls()
const {isMobile} = useWebMediaQueries()
// quote post with media
// =
@@ -129,10 +127,7 @@ export function PostEmbeds({
dimensionsHint={aspectRatio}
onPress={() => _openLightbox(0)}
onPressIn={() => onPressIn(0)}
style={[
styles.singleImage,
isMobile && styles.singleImageMobile,
]}>
style={[styles.singleImage]}>
{alt === '' ? null : (
<View style={styles.altContainer}>
<Text style={styles.alt} accessible={false}>
@@ -151,11 +146,7 @@ export function PostEmbeds({
images={embed.images}
onPress={_openLightbox}
onPressIn={onPressIn}
style={
embed.images.length === 1
? [styles.singleImage, isMobile && styles.singleImageMobile]
: undefined
}
style={embed.images.length === 1 ? [styles.singleImage] : undefined}
/>
</View>
)
@@ -188,10 +179,6 @@ const styles = StyleSheet.create({
},
singleImage: {
borderRadius: 8,
maxHeight: 1000,
},
singleImageMobile: {
maxHeight: 500,
},
extOuter: {
borderWidth: 1,
+6 -3
View File
@@ -1,4 +1,5 @@
import React from 'react'
import {StyleSheet, TextProps} from 'react-native'
import Svg, {
Path,
Defs,
@@ -14,12 +15,14 @@ const ratio = 57 / 64
type Props = {
fill?: PathProps['fill']
} & SvgProps
style?: TextProps['style']
} & Omit<SvgProps, 'style'>
export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) {
const {fill, ...rest} = props
const gradient = fill === 'sky'
const _fill = gradient ? 'url(#sky)' : fill || colors.blue3
const styles = StyleSheet.flatten(props.style)
const _fill = gradient ? 'url(#sky)' : fill || styles?.color || colors.blue3
// @ts-ignore it's fiiiiine
const size = parseInt(rest.width || 32)
return (
@@ -29,7 +32,7 @@ export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) {
ref={ref}
viewBox="0 0 64 57"
{...rest}
style={{width: size, height: size * ratio}}>
style={[{width: size, height: size * ratio}, styles]}>
{gradient && (
<Defs>
<LinearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
+6
View File
@@ -52,6 +52,7 @@ import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
import {faHand} from '@fortawesome/free-solid-svg-icons/faHand'
import {faHand as farHand} from '@fortawesome/free-regular-svg-icons/faHand'
import {faHashtag} from '@fortawesome/free-solid-svg-icons/faHashtag'
import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart'
import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart'
import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse'
@@ -71,6 +72,7 @@ import {faPaste} from '@fortawesome/free-regular-svg-icons/faPaste'
import {faPen} from '@fortawesome/free-solid-svg-icons/faPen'
import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare'
import {faPhone} from '@fortawesome/free-solid-svg-icons/faPhone'
import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus'
import {faQuoteLeft} from '@fortawesome/free-solid-svg-icons/faQuoteLeft'
@@ -78,6 +80,7 @@ import {faReply} from '@fortawesome/free-solid-svg-icons/faReply'
import {faRetweet} from '@fortawesome/free-solid-svg-icons/faRetweet'
import {faRss} from '@fortawesome/free-solid-svg-icons/faRss'
import {faSatelliteDish} from '@fortawesome/free-solid-svg-icons/faSatelliteDish'
import {faServer} from '@fortawesome/free-solid-svg-icons/faServer'
import {faShare} from '@fortawesome/free-solid-svg-icons/faShare'
import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare'
import {faShield} from '@fortawesome/free-solid-svg-icons/faShield'
@@ -153,6 +156,7 @@ library.add(
faGlobe,
faHand,
farHand,
faHashtag,
faHeart,
fasHeart,
faHouse,
@@ -172,6 +176,7 @@ library.add(
faPen,
faPenNib,
faPenToSquare,
faPhone,
faPlay,
faPlus,
faQuoteLeft,
@@ -179,6 +184,7 @@ library.add(
faRetweet,
faRss,
faSatelliteDish,
faServer,
faShare,
faShareFromSquare,
faShield,
-541
View File
@@ -1,541 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {CenteredView, ScrollView} from '#/view/com/util/Views'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useSetColorMode} from '#/state/shell'
import * as tokens from '#/alf/tokens'
import {atoms as a, useTheme, useBreakpoints, ThemeProvider as Alf} from '#/alf'
import {Button, ButtonText} from '#/view/com/Button'
import {Text, H1, H2, H3, H4, H5, H6} from '#/view/com/Typography'
function ThemeSelector() {
const setColorMode = useSetColorMode()
return (
<View style={[a.flex_row, a.gap_md]}>
<Button
type="secondary"
size="small"
onPress={() => setColorMode('system')}>
System
</Button>
<Button
type="secondary"
size="small"
onPress={() => setColorMode('light')}>
Light
</Button>
<Button
type="secondary"
size="small"
onPress={() => setColorMode('dark')}>
Dark
</Button>
</View>
)
}
function BreakpointDebugger() {
const t = useTheme()
const breakpoints = useBreakpoints()
return (
<View>
<H3 style={[a.pb_md]}>Breakpoint Debugger</H3>
<Text style={[a.pb_md]}>
Current breakpoint: {!breakpoints.gtMobile && <Text>mobile</Text>}
{breakpoints.gtMobile && !breakpoints.gtTablet && <Text>tablet</Text>}
{breakpoints.gtTablet && <Text>desktop</Text>}
</Text>
<Text
style={[a.p_md, t.atoms.bg_contrast_100, {fontFamily: 'monospace'}]}>
{JSON.stringify(breakpoints, null, 2)}
</Text>
</View>
)
}
function ThemedSection() {
const t = useTheme()
return (
<View style={[t.atoms.bg, a.gap_md, a.p_xl]}>
<H3 style={[a.font_bold]}>theme.atoms.text</H3>
<View style={[a.flex_1, t.atoms.border, a.border_t]} />
<H3 style={[a.font_bold, t.atoms.text_contrast_700]}>
theme.atoms.text_contrast_700
</H3>
<View style={[a.flex_1, t.atoms.border, a.border_t]} />
<H3 style={[a.font_bold, t.atoms.text_contrast_500]}>
theme.atoms.text_contrast_500
</H3>
<View style={[a.flex_1, t.atoms.border_contrast_500, a.border_t]} />
<View style={[a.flex_row, a.gap_md]}>
<View
style={[
a.flex_1,
t.atoms.bg,
a.align_center,
a.justify_center,
{height: 60},
]}>
<Text>theme.bg</Text>
</View>
<View
style={[
a.flex_1,
t.atoms.bg_contrast_100,
a.align_center,
a.justify_center,
{height: 60},
]}>
<Text>theme.bg_contrast_100</Text>
</View>
</View>
<View style={[a.flex_row, a.gap_md]}>
<View
style={[
a.flex_1,
t.atoms.bg_contrast_200,
a.align_center,
a.justify_center,
{height: 60},
]}>
<Text>theme.bg_contrast_200</Text>
</View>
<View
style={[
a.flex_1,
t.atoms.bg_contrast_300,
a.align_center,
a.justify_center,
{height: 60},
]}>
<Text>theme.bg_contrast_300</Text>
</View>
</View>
<View style={[a.flex_row, a.gap_md]}>
<View
style={[
a.flex_1,
t.atoms.bg_positive,
a.align_center,
a.justify_center,
{height: 60},
]}>
<Text>theme.bg_positive</Text>
</View>
<View
style={[
a.flex_1,
t.atoms.bg_negative,
a.align_center,
a.justify_center,
{height: 60},
]}>
<Text>theme.bg_negative</Text>
</View>
</View>
</View>
)
}
export function DebugScreen() {
const t = useTheme()
return (
<ScrollView>
<CenteredView style={[t.atoms.bg]}>
<View style={[a.p_xl, a.gap_xxl, {paddingBottom: 200}]}>
<ThemeSelector />
<Alf theme="light">
<ThemedSection />
</Alf>
<Alf theme="dark">
<ThemedSection />
</Alf>
<H1>Heading 1</H1>
<H2>Heading 2</H2>
<H3>Heading 3</H3>
<H4>Heading 4</H4>
<H5>Heading 5</H5>
<H6>Heading 6</H6>
<Text style={[a.text_xxl]}>atoms.text_xxl</Text>
<Text style={[a.text_xl]}>atoms.text_xl</Text>
<Text style={[a.text_lg]}>atoms.text_lg</Text>
<Text style={[a.text_md]}>atoms.text_md</Text>
<Text style={[a.text_sm]}>atoms.text_sm</Text>
<Text style={[a.text_xs]}>atoms.text_xs</Text>
<Text style={[a.text_xxs]}>atoms.text_xxs</Text>
<View style={[a.gap_md, a.align_start]}>
<Button>
{({state}) => (
<View style={[a.p_md, a.rounded_full, t.atoms.bg_contrast_300]}>
<Text>Unstyled button, state: {JSON.stringify(state)}</Text>
</View>
)}
</Button>
<Button type="primary" size="small">
Button
</Button>
<Button type="secondary" size="small">
Button
</Button>
<Button type="primary" size="large">
Button
</Button>
<Button type="secondary" size="large">
Button
</Button>
<Button type="secondary" size="small">
{({type, size}) => (
<>
<FontAwesomeIcon icon={['fas', 'plus']} size={12} />
<ButtonText type={type} size={size}>
With an icon
</ButtonText>
</>
)}
</Button>
<Button type="primary" size="large">
{({state: _state, ...rest}) => (
<>
<FontAwesomeIcon icon={['fas', 'plus']} />
<ButtonText {...rest}>With an icon</ButtonText>
</>
)}
</Button>
</View>
<View style={[a.gap_md]}>
<View style={[a.flex_row, a.gap_md]}>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.gray_0},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.gray_100},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.gray_200},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.gray_300},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.gray_400},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.gray_500},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.gray_600},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.gray_700},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.gray_800},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.gray_900},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.gray_1000},
]}
/>
</View>
<View style={[a.flex_row, a.gap_md]}>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_0},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_100},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_200},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_300},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_400},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_500},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_600},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_700},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_800},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_900},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_1000},
]}
/>
</View>
<View style={[a.flex_row, a.gap_md]}>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.green_0},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.green_100},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.green_200},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.green_300},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.green_400},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.green_500},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.green_600},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.green_700},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.green_800},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.green_900},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.green_1000},
]}
/>
</View>
<View style={[a.flex_row, a.gap_md]}>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_0},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_100},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_200},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_300},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_400},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_500},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_600},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_700},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_800},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_900},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_1000},
]}
/>
</View>
</View>
<View>
<H3 style={[a.pb_md, a.font_bold]}>Spacing</H3>
<View style={[a.gap_md]}>
<View style={[a.flex_row, a.align_center]}>
<Text style={{width: 80}}>xxs (2px)</Text>
<View style={[a.flex_1, a.pt_xxs, t.atoms.bg_contrast_300]} />
</View>
<View style={[a.flex_row, a.align_center]}>
<Text style={{width: 80}}>xs (4px)</Text>
<View style={[a.flex_1, a.pt_xs, t.atoms.bg_contrast_300]} />
</View>
<View style={[a.flex_row, a.align_center]}>
<Text style={{width: 80}}>sm (8px)</Text>
<View style={[a.flex_1, a.pt_sm, t.atoms.bg_contrast_300]} />
</View>
<View style={[a.flex_row, a.align_center]}>
<Text style={{width: 80}}>md (12px)</Text>
<View style={[a.flex_1, a.pt_md, t.atoms.bg_contrast_300]} />
</View>
<View style={[a.flex_row, a.align_center]}>
<Text style={{width: 80}}>lg (18px)</Text>
<View style={[a.flex_1, a.pt_lg, t.atoms.bg_contrast_300]} />
</View>
<View style={[a.flex_row, a.align_center]}>
<Text style={{width: 80}}>xl (24px)</Text>
<View style={[a.flex_1, a.pt_xl, t.atoms.bg_contrast_300]} />
</View>
<View style={[a.flex_row, a.align_center]}>
<Text style={{width: 80}}>xxl (32px)</Text>
<View style={[a.flex_1, a.pt_xxl, t.atoms.bg_contrast_300]} />
</View>
</View>
</View>
<BreakpointDebugger />
</View>
</CenteredView>
</ScrollView>
)
}
+1 -2
View File
@@ -19,7 +19,6 @@ import {useSession} from '#/state/session'
import {loadString, saveString} from '#/lib/storage'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {clamp} from '#/lib/numbers'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
export function HomeScreen(props: Props) {
@@ -112,7 +111,7 @@ function HomeScreenReady({
mergeFeedEnabled: Boolean(preferences.feedViewPrefs.lab_mergeFeedEnabled),
mergeFeedSources: preferences.feedViewPrefs.lab_mergeFeedEnabled
? preferences.feeds.saved
: [PROD_DEFAULT_FEED('whats-hot')],
: [],
}
}, [preferences])
+37 -17
View File
@@ -42,9 +42,13 @@ import {useSetDrawerOpen} from '#/state/shell'
import {useAnalytics} from '#/lib/analytics/analytics'
import {MagnifyingGlassIcon} from '#/lib/icons'
import {useModerationOpts} from '#/state/queries/preferences'
import {SearchResultCard} from '#/view/shell/desktop/Search'
import {
MATCH_HANDLE,
SearchLinkCard,
SearchProfileCard,
} from '#/view/shell/desktop/Search'
import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
import {isWeb} from '#/platform/detection'
import {isNative, isWeb} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {s} from '#/lib/styles'
@@ -83,9 +87,7 @@ function EmptyState({message, error}: {message: string; error?: string}) {
},
]}>
<View style={[pal.viewLight, {padding: 18, borderRadius: 8}]}>
<Text style={[pal.text]}>
<Trans>{message}</Trans>
</Text>
<Text style={[pal.text]}>{message}</Text>
{error && (
<>
@@ -511,6 +513,11 @@ export function SearchScreen(
onPressCancelSearch()
}, [onPressCancelSearch])
const queryMaybeHandle = React.useMemo(() => {
const match = MATCH_HANDLE.exec(query)
return match && match[1]
}, [query])
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
@@ -617,18 +624,31 @@ export function SearchScreen(
dataSet={{stableGutters: '1'}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag">
{searchResults.length ? (
searchResults.map((item, i) => (
<SearchResultCard
key={item.did}
profile={item}
moderation={moderateProfile(item, moderationOpts)}
style={i === 0 ? {borderTopWidth: 0} : {}}
/>
))
) : (
<EmptyState message={_(msg`No results found for ${query}`)} />
)}
<SearchLinkCard
label={_(msg`Search for "${query}"`)}
onPress={isNative ? onSubmit : undefined}
to={
isNative
? undefined
: `/search?q=${encodeURIComponent(query)}`
}
style={{borderBottomWidth: 1}}
/>
{queryMaybeHandle ? (
<SearchLinkCard
label={_(msg`Go to @${queryMaybeHandle}`)}
to={`/profile/${queryMaybeHandle}`}
/>
) : null}
{searchResults.map(item => (
<SearchProfileCard
key={item.did}
profile={item}
moderation={moderateProfile(item, moderationOpts)}
/>
))}
<View style={{height: 200}} />
</ScrollView>

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