Merge remote-tracking branch 'origin/main' into hailey/mod-metrics

* origin/main: (108 commits)
  tweak ci (#7916)
  Merge #7903 (#7917)
  fixing moderation blur gap when replying via the thread feed (#7783)
  fix cancel button on search posts screen (#7912)
  Add context for toast messages (#7902)
  Filter searchable people list by accepted (#7915)
  Nightly source-language update
  Add report dialog e2e tests (#7913)
  ota helper in dev mode (#7911)
  Add language: Esperanto (#7863)
  Add language: Scottish Gaelic (#7864)
  Add language: Welsh (#7866)
  Add initial test for post report flow
  mark label for translation (#7908)
  skeletons for chat list (#7907)
  fix layout shift in profile feed header (#7906)
  Add dark mode option to in-app embed snippet dialog (#7377)
  enhance(embed): add ability to pin color mode (#7186)
  Nightly source-language update
  use latest macos/xcode (#7893)
  ...
This commit is contained in:
Eric Bailey
2025-03-06 13:57:28 -06:00
362 changed files with 55063 additions and 65352 deletions
+2 -15
View File
@@ -32,6 +32,7 @@ module.exports = {
'H6', 'H6',
'P', 'P',
'Admonition', 'Admonition',
'Admonition.Admonition',
], ],
impliedTextProps: [], impliedTextProps: [],
suggestedTextWrappers: { suggestedTextWrappers: {
@@ -80,20 +81,6 @@ module.exports = {
], ],
'simple-import-sort/exports': 'error', 'simple-import-sort/exports': 'error',
'react-compiler/react-compiler': 'warn', 'react-compiler/react-compiler': 'warn',
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@atproto/api',
importNames: ['moderatePost'],
message:
'Please use `moderatePost_wrapped` from `#/lib/moderatePost_wrapped` instead.',
},
],
},
],
'@typescript-eslint/ban-ts-comment': 'warn',
}, },
ignorePatterns: [ ignorePatterns: [
'**/__mocks__/*.ts', '**/__mocks__/*.ts',
@@ -105,9 +92,9 @@ module.exports = {
'*.lock', '*.lock',
'.husky', '.husky',
'patches', 'patches',
'bskyweb',
'*.html', '*.html',
'bskyweb', 'bskyweb',
'bskyembed',
'src/locale/locales/_build/', 'src/locale/locales/_build/',
'src/locale/locales/**/*.js', 'src/locale/locales/**/*.js',
], ],
+2 -2
View File
@@ -14,7 +14,7 @@ on:
jobs: jobs:
build: build:
name: Build and Submit iOS name: Build and Submit iOS
runs-on: macos-14 runs-on: macos-15
steps: steps:
- name: Check for EXPO_TOKEN - name: Check for EXPO_TOKEN
run: > run: >
@@ -52,7 +52,7 @@ jobs:
- uses: maxim-lobanov/setup-xcode@v1 - uses: maxim-lobanov/setup-xcode@v1
with: with:
xcode-version: '15.3' xcode-version: '16.2'
- name: ☕️ Setup Cocoapods - name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1 uses: maxim-lobanov/setup-cocoapods@v1
@@ -134,7 +134,7 @@ jobs:
# GitHub actions are horrible so let's just copy paste this in # GitHub actions are horrible so let's just copy paste this in
buildIfNecessaryIOS: buildIfNecessaryIOS:
name: Build and Submit iOS name: Build and Submit iOS
runs-on: macos-14 runs-on: macos-15
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
cancel-in-progress: true cancel-in-progress: true
@@ -176,7 +176,7 @@ jobs:
- uses: maxim-lobanov/setup-xcode@v1 - uses: maxim-lobanov/setup-xcode@v1
with: with:
xcode-version: '15.3' xcode-version: '16.2'
- name: ☕️ Setup Cocoapods - name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1 uses: maxim-lobanov/setup-cocoapods@v1
@@ -36,3 +36,14 @@ jobs:
with: with:
commit_message: Nightly source-language update commit_message: Nightly source-language update
file_pattern: ./src/locale/locales/en/messages.po file_pattern: ./src/locale/locales/en/messages.po
- name: Push source lang to Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_sources_args: "-b main"
upload_translations: false
push_translations: false
push_sources: false
create_pull_request: false
env:
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+2 -2
View File
@@ -37,6 +37,6 @@ deps: ## Installs dependent libs using 'yarn install'
.PHONY: nvm-setup .PHONY: nvm-setup
nvm-setup: ## Use NVM to install and activate node+yarn nvm-setup: ## Use NVM to install and activate node+yarn
nvm install 18 nvm install 20
nvm use 18 nvm use 20
npm install --global yarn npm install --global yarn
+1 -1
View File
@@ -1,2 +1,2 @@
flows: flows:
- "flows/*" - "flows/**"
+1 -1
View File
@@ -160,7 +160,7 @@ appId: xyz.blueskyweb.app
- tapOn: - tapOn:
label: "Adds and removes users on curatelists from the profile" label: "Adds and removes users on curatelists from the profile"
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- tapOn: "Search" - tapOn: "Search for posts, users, or feeds"
- inputText: "bob" - inputText: "bob"
- tapOn: - tapOn:
id: "searchAutoCompleteResult-bob.test" id: "searchAutoCompleteResult-bob.test"
+1 -1
View File
@@ -16,7 +16,7 @@ appId: xyz.blueskyweb.app
- tapOn: - tapOn:
id: "profileCardButton" id: "profileCardButton"
- tapOn: - tapOn:
id: "profilePager-selector-4" id: "profilePager-selector-5"
- tapOn: "alice-favs" - tapOn: "alice-favs"
- tapOn: "Pin to Home" - tapOn: "Pin to Home"
- tapOn: - tapOn:
+20
View File
@@ -0,0 +1,20 @@
appId: xyz.blueskyweb.app
---
- runScript:
file: ../setupServer.js
env:
SERVER_PATH: "?users&follows&posts"
- runFlow:
file: ../setupApp.yml
- tapOn:
id: "e2eSignInAlice"
- tapOn:
id: "postDropdownBtn"
index: 0
- tapOn:
id: "postDropdownReportBtn"
- tapOn: "Create report for Misleading Post"
- tapOn: "Send report to Dev-env Moderation"
- tapOn:
point: "50%,90%"
+1 -1
View File
@@ -15,7 +15,7 @@ appId: xyz.blueskyweb.app
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- tapOn: - tapOn:
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- tapOn: "Search" - tapOn: "Search for posts, users, or feeds"
- inputText: "b" - inputText: "b"
- tapOn: - tapOn:
id: "searchAutoCompleteResult-bob.test" id: "searchAutoCompleteResult-bob.test"
@@ -0,0 +1,24 @@
appId: xyz.blueskyweb.app
---
- runScript:
file: ../../setupServer.js
env:
SERVER_PATH: ?users&follows&posts&feeds
- runFlow:
file: ../../setupApp.yml
- tapOn:
id: "e2eSignInAlice"
- tapOn: "Carla's avatar"
- tapOn:
id: "profileHeaderDropdownBtn"
- tapOn:
id: "profileHeaderDropdownReportBtn"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonSpam"
- assertVisible:
id: "report:labeler:mod-authority.test"
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
@@ -0,0 +1,24 @@
appId: xyz.blueskyweb.app
---
- runScript:
file: ../../setupServer.js
env:
SERVER_PATH: ?users&follows&posts&feeds
- runFlow:
file: ../../setupApp.yml
- tapOn:
id: "e2eSignInAlice"
- tapOn:
id: "postDropdownBtn"
index: 0
- tapOn:
id: "postDropdownReportBtn"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonSpam"
- assertVisible:
id: "report:labeler:mod-authority.test"
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
@@ -0,0 +1,35 @@
appId: xyz.blueskyweb.app
---
- runScript:
file: ../../setupServer.js
env:
SERVER_PATH: ?users&follows&posts&feeds
- runFlow:
file: ../../setupApp.yml
- tapOn:
id: "e2eSignInAlice"
- tapOn:
id: "postDropdownBtn"
index: 0
- tapOn:
id: "postDropdownReportBtn"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonOther"
- assertVisible:
id: "report:labeler:mod-authority.test"
# reason "other" defaults with details open
- assertVisible:
id: "report:details"
- tapOn:
id: "report:clearOption"
- assertNotVisible:
id: "report:details"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonSpam"
- assertVisible:
id: "report:labeler:mod-authority.test"
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
@@ -0,0 +1,30 @@
appId: xyz.blueskyweb.app
---
- runScript:
file: ../../setupServer.js
env:
SERVER_PATH: ?users&follows&posts&feeds
- runFlow:
file: ../../setupApp.yml
- tapOn:
id: "e2eSignInAlice"
- tapOn:
id: "postDropdownBtn"
index: 0
- tapOn:
id: "postDropdownReportBtn"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonOther"
- assertVisible:
id: "report:labeler:mod-authority.test"
# reason "other" defaults with details open
- assertVisible:
id: "report:details"
- tapOn:
id: "report:details"
- inputText: "This is a test report"
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
+2 -2
View File
@@ -12,8 +12,8 @@ appId: xyz.blueskyweb.app
# Navigate to another user profile via autocomplete # Navigate to another user profile via autocomplete
- tapOn: - tapOn:
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- assertVisible: "Search" - assertVisible: "Search for posts, users, or feeds"
- tapOn: "Search" - tapOn: "Search for posts, users, or feeds"
- inputText: "b" - inputText: "b"
- tapOn: - tapOn:
id: "searchAutoCompleteResult-bob.test" id: "searchAutoCompleteResult-bob.test"
+4
View File
@@ -75,14 +75,17 @@ module.exports = function (config) {
'an', 'an',
'ast', 'ast',
'ca', 'ca',
'cy',
'da', 'da',
'de', 'de',
'el', 'el',
'eo',
'es', 'es',
'eu', 'eu',
'fi', 'fi',
'fr', 'fr',
'ga', 'ga',
'gd',
'gl', 'gl',
'hi', 'hi',
'hu', 'hu',
@@ -242,6 +245,7 @@ module.exports = function (config) {
'./plugins/withAndroidStylesAccentColorPlugin.js', './plugins/withAndroidStylesAccentColorPlugin.js',
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js', './plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
'./plugins/withAndroidNoJitpackPlugin.js', './plugins/withAndroidNoJitpackPlugin.js',
'./plugins/withNoBundleCompression.js',
'./plugins/shareExtension/withShareExtensions.js', './plugins/shareExtension/withShareExtensions.js',
'./plugins/notificationsExtension/withNotificationsExtension.js', './plugins/notificationsExtension/withNotificationsExtension.js',
'./plugins/withAppDelegateReferrer.js', './plugins/withAppDelegateReferrer.js',
@@ -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="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.293-3.707a1 1 0 0 1 1.414 0L12 10.586l2.293-2.293a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 0 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 454 B

+1
View File
@@ -68,6 +68,7 @@ function scan(node = document) {
if (ref_url.startsWith('http')) { if (ref_url.startsWith('http')) {
searchParams.set('ref_url', encodeURIComponent(ref_url)) searchParams.set('ref_url', encodeURIComponent(ref_url))
} }
searchParams.set('colorMode', embed.dataset.blueskyColorMode || 'system')
const iframe = document.createElement('iframe') const iframe = document.createElement('iframe')
iframe.setAttribute('data-bluesky-id', id) iframe.setAttribute('data-bluesky-id', id)
+7 -1
View File
@@ -1,9 +1,15 @@
export type ColorModeValues = 'system' | 'light' | 'dark'
export function assertColorModeValues(value: string): value is ColorModeValues {
return ['system', 'light', 'dark'].includes(value)
}
export function applyTheme(theme: 'light' | 'dark') { export function applyTheme(theme: 'light' | 'dark') {
document.documentElement.classList.remove('light', 'dark') document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(theme) document.documentElement.classList.add(theme)
} }
export function initColorMode() { export function initSystemColorMode() {
applyTheme( applyTheme(
window.matchMedia('(prefers-color-scheme: dark)').matches window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' ? 'dark'
+11
View File
@@ -9,3 +9,14 @@
:root { :root {
color-scheme: light dark; color-scheme: light dark;
} }
select {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' height='14px' width='14px' fill='none' viewBox='0 0 24 24'><path fill='black' fill-rule='evenodd' d='M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z' clip-rule='evenodd'/></svg>");
background-repeat: no-repeat;
background-position: calc(100% - 0.75rem) center;
padding-right: 2rem;
@media (prefers-color-scheme: dark) {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' height='14px' width='14px' fill='none' viewBox='0 0 24 24'><path fill='white' fill-rule='evenodd' d='M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z' clip-rule='evenodd'/></svg>");
}
}
+1
View File
@@ -13,6 +13,7 @@ export function labelsToInfo(
return 'Adult Content' return 'Adult Content'
case 'nudity': case 'nudity':
return 'Non-sexual Nudity' return 'Non-sexual Nudity'
case 'gore':
case 'graphic-media': case 'graphic-media':
return 'Graphic Media' return 'Graphic Media'
default: default:
+58 -19
View File
@@ -1,12 +1,16 @@
import '../index.css' import '../index.css'
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri, BskyAgent} from '@atproto/api' import {AppBskyFeedDefs, AppBskyFeedPost, AtpAgent, AtUri} from '@atproto/api'
import {h, render} from 'preact' import {h, render} from 'preact'
import {useEffect, useMemo, useRef, useState} from 'preact/hooks' import {useEffect, useMemo, useRef, useState} from 'preact/hooks'
import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg' import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg' import logo from '../../assets/logo.svg'
import {initColorMode} from '../color-mode' import {
assertColorModeValues,
ColorModeValues,
initSystemColorMode,
} from '../color-mode'
import {Container} from '../components/container' import {Container} from '../components/container'
import {Link} from '../components/link' import {Link} from '../components/link'
import {Post} from '../components/post' import {Post} from '../components/post'
@@ -22,9 +26,9 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
const root = document.getElementById('app') const root = document.getElementById('app')
if (!root) throw new Error('No root element') if (!root) throw new Error('No root element')
initColorMode() initSystemColorMode()
const agent = new BskyAgent({ const agent = new AtpAgent({
service: 'https://public.api.bsky.app', service: 'https://public.api.bsky.app',
}) })
@@ -32,6 +36,7 @@ render(<LandingPage />, root)
function LandingPage() { function LandingPage() {
const [uri, setUri] = useState('') const [uri, setUri] = useState('')
const [colorMode, setColorMode] = useState<ColorModeValues>('system')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>( const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
@@ -120,24 +125,50 @@ function LandingPage() {
<h1 className="text-4xl font-bold text-center">Embed a Bluesky Post</h1> <h1 className="text-4xl font-bold text-center">Embed a Bluesky Post</h1>
<input <div className="flex flex-col w-full max-w-[600px] gap-6">
type="text" <input
value={uri} type="text"
onInput={e => setUri(e.currentTarget.value)} value={uri}
className="border rounded-lg py-3 w-full max-w-[600px] px-4 dark:bg-dimmedBg dark:border-slate-500" onInput={e => setUri(e.currentTarget.value)}
placeholder={DEFAULT_POST} className="border rounded-lg py-3 px-4 dark:bg-dimmedBg dark:border-slate-500"
/> placeholder={DEFAULT_POST}
/>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium" for="colorModeSelect">
Theme
</label>
<select
value={colorMode}
onChange={e => {
const value = e.currentTarget.value
if (assertColorModeValues(value)) {
setColorMode(value)
}
}}
id="colorModeSelect"
className="appearance-none bg-white border w-full rounded-lg text-sm px-3 py-2 dark:bg-dimmedBg dark:border-slate-500">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
<img src={arrowBottom} className="w-6 dark:invert" /> <img src={arrowBottom} className="w-6 dark:invert" />
{loading ? ( {loading ? (
<div className="w-full max-w-[600px]"> <div className={`${colorMode} w-full max-w-[600px]`}>
<Skeleton /> <Skeleton />
</div> </div>
) : ( ) : (
<div className="w-full max-w-[600px] gap-8 flex flex-col"> <div className="w-full max-w-[600px] gap-8 flex flex-col">
{!error && thread && uri && <Snippet thread={thread} />} {!error && thread && uri && (
{!error && thread && <Post thread={thread} key={thread.post.uri} />} <Snippet thread={thread} colorMode={colorMode} />
)}
<div className={colorMode}>
{!error && thread && <Post thread={thread} key={thread.post.uri} />}
</div>
{error && ( {error && (
<div className="w-full border border-red-500 bg-red-500/10 px-4 py-3 rounded-lg"> <div className="w-full border border-red-500 bg-red-500/10 px-4 py-3 rounded-lg">
<p className="text-red-500 text-center">{error}</p> <p className="text-red-500 text-center">{error}</p>
@@ -168,7 +199,13 @@ function Skeleton() {
) )
} }
function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) { function Snippet({
thread,
colorMode,
}: {
thread: AppBskyFeedDefs.ThreadViewPost
colorMode: ColorModeValues
}) {
const ref = useRef<HTMLInputElement>(null) const ref = useRef<HTMLInputElement>(null)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@@ -204,9 +241,11 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x // x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml( return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
thread.post.uri, thread.post.uri,
)}" data-bluesky-cid="${escapeHtml(thread.post.cid)}"><p lang="${escapeHtml( )}" data-bluesky-cid="${escapeHtml(
lang, thread.post.cid,
)}">${escapeHtml(record.text)}${ )}" data-bluesky-embed-color-mode="${escapeHtml(
colorMode,
)}"><p lang="${escapeHtml(lang)}">${escapeHtml(record.text)}${
record.embed record.embed
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>` ? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
: '' : ''
@@ -217,7 +256,7 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml( )}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
niceDate(thread.post.indexedAt), niceDate(thread.post.indexedAt),
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>` )}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
}, [thread]) }, [thread, colorMode])
return ( return (
<div className="flex gap-2 w-full"> <div className="flex gap-2 w-full">
+18 -2
View File
@@ -4,7 +4,7 @@ import {AppBskyFeedDefs, AtpAgent} from '@atproto/api'
import {h, render} from 'preact' import {h, render} from 'preact'
import logo from '../../assets/logo.svg' import logo from '../../assets/logo.svg'
import {initColorMode} from '../color-mode' import {applyTheme, initSystemColorMode} from '../color-mode'
import {Container} from '../components/container' import {Container} from '../components/container'
import {Link} from '../components/link' import {Link} from '../components/link'
import {Post} from '../components/post' import {Post} from '../components/post'
@@ -22,7 +22,23 @@ if (!uri) {
throw new Error('No uri in path') throw new Error('No uri in path')
} }
initColorMode() const query = new URLSearchParams(window.location.search)
// theme - default to light mode
const colorMode = query.get('colorMode')
switch (colorMode) {
case 'dark':
applyTheme('dark')
break
case 'system':
initSystemColorMode()
break
case 'light':
default:
applyTheme('light')
break
}
agent agent
.getPostThread({ .getPostThread({
+5 -3
View File
@@ -1,9 +1,10 @@
import {Express} from 'express' import {Express} from 'express'
import {AppContext} from '../context.js' import {AppContext} from '../context.js'
import {default as create} from './create.js' import {default as createShortLink} from './createShortLink.js'
import {default as health} from './health.js' import {default as health} from './health.js'
import {default as redirect} from './redirect.js' import {default as redirect} from './redirect.js'
import {default as shortLink} from './shortLink.js'
import {default as siteAssociation} from './siteAssociation.js' import {default as siteAssociation} from './siteAssociation.js'
export * from './util.js' export * from './util.js'
@@ -11,7 +12,8 @@ export * from './util.js'
export default function (ctx: AppContext, app: Express) { export default function (ctx: AppContext, app: Express) {
app = health(ctx, app) // GET /_health app = health(ctx, app) // GET /_health
app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association
app = create(ctx, app) // POST /link app = redirect(ctx, app) // GET /redirect
app = redirect(ctx, app) // GET /:linkId (should go last due to permissive matching) app = createShortLink(ctx, app) // POST /link
app = shortLink(ctx, app) // GET /:linkId (should go last due to permissive matching)
return app return app
} }
+24 -31
View File
@@ -6,47 +6,40 @@ import {Express} from 'express'
import {AppContext} from '../context.js' import {AppContext} from '../context.js'
import {handler} from './util.js' import {handler} from './util.js'
const INTERNAL_IP_REGEX = new RegExp(
'(^127.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$)|(^10.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.1[6-9]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.2[0-9]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.3[0-1]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^192.168.[0-9]{1,3}.[0-9]{1,3}$)|^localhost',
'i',
)
export default function (ctx: AppContext, app: Express) { export default function (ctx: AppContext, app: Express) {
return app.get( return app.get(
'/:linkId', '/redirect',
handler(async (req, res) => { handler(async (req, res) => {
const linkId = req.params.linkId let link = req.query.u
const contentType = req.accepts(['html', 'json'])
assert( assert(
typeof linkId === 'string', typeof link === 'string',
'express guarantees id parameter is a string', 'express guarantees link query parameter is a string',
) )
const found = await ctx.db.db link = decodeURIComponent(link)
.selectFrom('link')
.selectAll() let url: URL | undefined
.where('id', '=', linkId) try {
.executeTakeFirst() url = new URL(link)
if (!found) { } catch {}
// potentially broken or mistyped link
if (
!url ||
(url.protocol !== 'http:' && url.protocol !== 'https:') || // is a http(s) url
(ctx.cfg.service.hostnames.includes(url.hostname.toLowerCase()) &&
url.pathname === '/redirect') || // is a redirect loop
INTERNAL_IP_REGEX.test(url.hostname) // isn't directing to an internal location
) {
res.setHeader('Cache-Control', 'no-store') res.setHeader('Cache-Control', 'no-store')
if (contentType === 'json') {
return res
.status(404)
.json({
error: 'NotFound',
message: 'Link not found',
})
.end()
}
// send the user to the app
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`) res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
return res.status(302).end() return res.status(302).end()
} }
// build url from original url in order to preserve query params
const url = new URL(
req.originalUrl,
`https://${ctx.cfg.service.appHostname}`,
)
url.pathname = found.path
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`) res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
if (contentType === 'json') {
return res.json({url: url.href}).end()
}
res.setHeader('Location', url.href) res.setHeader('Location', url.href)
return res.status(301).end() return res.status(301).end()
}), }),
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert'
import {DAY, SECOND} from '@atproto/common'
import {Express} from 'express'
import {AppContext} from '../context.js'
import {handler} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/:linkId',
handler(async (req, res) => {
const linkId = req.params.linkId
const contentType = req.accepts(['html', 'json'])
assert(
typeof linkId === 'string',
'express guarantees id parameter is a string',
)
const found = await ctx.db.db
.selectFrom('link')
.selectAll()
.where('id', '=', linkId)
.executeTakeFirst()
if (!found) {
// potentially broken or mistyped link
res.setHeader('Cache-Control', 'no-store')
if (contentType === 'json') {
return res
.status(404)
.json({
error: 'NotFound',
message: 'Link not found',
})
.end()
}
// send the user to the app
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
return res.status(302).end()
}
// build url from original url in order to preserve query params
const url = new URL(
req.originalUrl,
`https://${ctx.cfg.service.appHostname}`,
)
url.pathname = found.path
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
if (contentType === 'json') {
return res.json({url: url.href}).end()
}
res.setHeader('Location', url.href)
return res.status(301).end()
}),
)
}
+1
View File
@@ -97,6 +97,7 @@ const hideAvatarLabels = new Set([
'nudity', 'nudity',
'sexual-figurative', 'sexual-figurative',
'graphic-media', 'graphic-media',
'gore',
'self-harm', 'self-harm',
'sensitive', 'sensitive',
'security', 'security',
+14
View File
@@ -94,6 +94,20 @@ func run(args []string) {
Value: "", Value: "",
EnvVars: []string{"STATIC_CDN_HOST"}, EnvVars: []string{"STATIC_CDN_HOST"},
}, },
&cli.BoolFlag{
Name: "bsky-canonical-instance",
Usage: "Enable if this is the canonical deployment (bsky.app)",
Value: false,
Required: false,
EnvVars: []string{"BSKY_CANONICAL_INSTANCE"},
},
&cli.BoolFlag{
Name: "robots-disallow-all",
Usage: "Crawling is allowed by default. Enable this flag to Disallow all",
Value: false,
Required: false,
EnvVars: []string{"ROBOTS_DISALLOW_ALL"},
},
}, },
}, },
} }
+20 -7
View File
@@ -63,6 +63,8 @@ func serve(cctx *cli.Context) error {
corsOrigins := cctx.StringSlice("cors-allowed-origins") corsOrigins := cctx.StringSlice("cors-allowed-origins")
staticCDNHost := cctx.String("static-cdn-host") staticCDNHost := cctx.String("static-cdn-host")
staticCDNHost = strings.TrimSuffix(staticCDNHost, "/") staticCDNHost = strings.TrimSuffix(staticCDNHost, "/")
canonicalInstance := cctx.Bool("bsky-canonical-instance")
robotsDisallowAll := cctx.Bool("robots-disallow-all")
// Echo // Echo
e := echo.New() e := echo.New()
@@ -204,13 +206,23 @@ func serve(cctx *cli.Context) error {
return http.FS(fsys) return http.FS(fsys)
}()) }())
e.GET("/robots.txt", echo.WrapHandler(staticHandler)) // enable some special endpoints for the "canonical" deployment (bsky.app). not having these enabled should *not* impact regular operation
e.GET("/ips-v4", echo.WrapHandler(staticHandler)) if canonicalInstance {
e.GET("/ips-v6", echo.WrapHandler(staticHandler)) e.GET("/ips-v4", echo.WrapHandler(staticHandler))
e.GET("/.well-known/*", echo.WrapHandler(staticHandler)) e.GET("/ips-v6", echo.WrapHandler(staticHandler))
e.GET("/security.txt", func(c echo.Context) error { e.GET("/security.txt", func(c echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt") return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
}) })
e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
}
// default to permissive, but Disallow all if flag set
if robotsDisallowAll {
e.File("/robots.txt", "static/robots-disallow-all.txt")
} else {
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
}
e.GET("/iframe/youtube.html", echo.WrapHandler(staticHandler)) e.GET("/iframe/youtube.html", echo.WrapHandler(staticHandler))
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc { e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
@@ -283,6 +295,7 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID/follows", server.WebGeneric) e.GET("/profile/:handleOrDID/follows", server.WebGeneric)
e.GET("/profile/:handleOrDID/followers", server.WebGeneric) e.GET("/profile/:handleOrDID/followers", server.WebGeneric)
e.GET("/profile/:handleOrDID/known-followers", server.WebGeneric) e.GET("/profile/:handleOrDID/known-followers", server.WebGeneric)
e.GET("/profile/:handleOrDID/search", server.WebGeneric)
e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric) e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric)
e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric) e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric) e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
+3
View File
@@ -0,0 +1,3 @@
# This is an development or self-hosted instance of the bsky web app, and crawling has been disallowed by the operator team.
User-Agent: *
Disallow: /
+2 -1
View File
@@ -1,5 +1,6 @@
{ {
"project_id": "664276", "project_id": "1",
"base_url": "https://bluesky.api.crowdin.com",
"base_path": ".", "base_path": ".",
"preserve_hierarchy": true, "preserve_hierarchy": true,
"files": [ "files": [
+7 -6
View File
@@ -1,13 +1,13 @@
# Internationalization # Internationalization
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please open a PR or issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app) We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/u/projects/1) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
## Tools ## Tools
- We use Lingui to implement translations. You can find the documentation [here](https://lingui.dev/).
- We use Crowdin to manage translations. - We use Crowdin to manage translations.
- Bluesky Crowdin: https://crowdin.com/project/bluesky-social - Bluesky Crowdin: https://bluesky.crowdin.com/u/projects/1
- Introduction to Crowdin: https://support.crowdin.com/for-translators/ - Introduction to Crowdin: https://support.crowdin.com/for-translators/
- We use Lingui to implement translations. You can find the documentation [here](https://lingui.dev/).
## Translators ## Translators
@@ -15,7 +15,7 @@ Much of the app is translated by community contributions. (We <3 our translators
### Using Crowdin ### Using Crowdin
[Crowdin](https://crowdin.com/project/bluesky-social) is our primary tool for managing translations. There are two roles: [Crowdin](https://bluesky.crowdin.com/u/projects/1) is our primary tool for managing translations. There are two roles:
- **Proof-readers**. Can create new translations and approve submitted translations. - **Proof-readers**. Can create new translations and approve submitted translations.
- **Translators**. Can create new translations. - **Translators**. Can create new translations.
@@ -32,7 +32,7 @@ Please treat everyone with respect. Proof-readers are given final say on transla
### Adding a new language ### Adding a new language
Create a new [Crowdin discussion](https://crowdin.com/project/bluesky-social/discussions) or [GitHub issue](https://github.com/bluesky-social/social-app/issues) requesting the new language be added to the project. You can request a new language be added to the project by clicking **Request New Language** on Crowdin or you can create a [GitHub issue](https://github.com/bluesky-social/social-app/issues).
Please only request a new language when you are certain you will be able to contribute a substantive portion of translations for the language. Please only request a new language when you are certain you will be able to contribute a substantive portion of translations for the language.
@@ -47,7 +47,8 @@ Every night, a GitHub action will run `yarn intl:extract` to update the english
### Release process ### Release process
1. Pull main and create a branch. 1. Pull main and create a branch.
1. Run `yarn intl:pull` to fetch all translation updates from Crowdin. 1. Run `yarn intl:pull` to fetch all translation updates from Crowdin. Commit.
1. Run `yarn intl:extract:all` to ensure all `.po` files are synced with the current state of the code. Commit.
1. Create a PR, ensure the translations all look correct, and merge. 1. Create a PR, ensure the translations all look correct, and merge.
1. If needed: 1. If needed:
1. Merge all approved translation PRs (contributions from outside crowdin). 1. Merge all approved translation PRs (contributions from outside crowdin).
+1 -1
View File
@@ -119,5 +119,5 @@ jest.mock('statsig-react-native-expo', () => ({
}, },
})) }))
jest.mock('../src/lib/bitdrift', () => ({})) jest.mock('../src/logger/bitdrift/lib', () => ({}))
jest.mock('../src/lib/statsig/statsig', () => ({})) jest.mock('../src/lib/statsig/statsig', () => ({}))
+3
View File
@@ -5,15 +5,18 @@ module.exports = {
'an', 'an',
'ast', 'ast',
'ca', 'ca',
'cy',
'da', 'da',
'de', 'de',
'el', 'el',
'en-GB', 'en-GB',
'eo',
'es', 'es',
'eu', 'eu',
'fi', 'fi',
'fr', 'fr',
'ga', 'ga',
'gd',
'gl', 'gl',
'hi', 'hi',
'hu', 'hu',
@@ -31,9 +31,13 @@ class BottomSheetView(
private lateinit var dialogRootViewGroup: DialogRootViewGroup private lateinit var dialogRootViewGroup: DialogRootViewGroup
private var eventDispatcher: EventDispatcher? = null private var eventDispatcher: EventDispatcher? = null
private val screenHeight = private val rawScreenHeight = context.resources.displayMetrics.heightPixels.toFloat()
context.resources.displayMetrics.heightPixels private val safeScreenHeight = (rawScreenHeight - getNavigationBarHeight()).toFloat()
.toFloat()
private fun getNavigationBarHeight(): Int {
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
}
private val onAttemptDismiss by EventDispatcher() private val onAttemptDismiss by EventDispatcher()
private val onSnapPointChange by EventDispatcher() private val onSnapPointChange by EventDispatcher()
@@ -63,12 +67,12 @@ class BottomSheetView(
} }
} }
var maxHeight = this.screenHeight var maxHeight = this.safeScreenHeight
set(value) { set(value) {
val px = dpToPx(value) val px = dpToPx(value)
field = field =
if (px > this.screenHeight) { if (px > this.safeScreenHeight) {
this.screenHeight this.safeScreenHeight
} else { } else {
px px
} }
@@ -153,6 +157,19 @@ class BottomSheetView(
// Presentation // Presentation
private fun getHalfExpandedRatio(contentHeight: Float): Float {
return when {
// Full height sheets
contentHeight >= safeScreenHeight -> 0.99f
// Medium height sheets (>50% but <100%)
contentHeight >= safeScreenHeight / 2 ->
this.clampRatio(this.getTargetHeight() / safeScreenHeight)
// Small height sheets (<50%)
else ->
this.clampRatio(this.getTargetHeight() / rawScreenHeight)
}
}
private fun present() { private fun present() {
if (this.isOpen || this.isOpening || this.isClosing) return if (this.isOpen || this.isOpening || this.isClosing) return
@@ -172,12 +189,12 @@ class BottomSheetView(
val behavior = BottomSheetBehavior.from(it) val behavior = BottomSheetBehavior.from(it)
behavior.state = BottomSheetBehavior.STATE_HIDDEN behavior.state = BottomSheetBehavior.STATE_HIDDEN
behavior.isFitToContents = true behavior.isFitToContents = true
behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight) behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
behavior.skipCollapsed = true behavior.skipCollapsed = true
behavior.isDraggable = true behavior.isDraggable = true
behavior.isHideable = true behavior.isHideable = true
if (contentHeight >= this.screenHeight || this.minHeight >= this.screenHeight) { if (contentHeight >= this.safeScreenHeight || this.minHeight >= this.safeScreenHeight) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED behavior.state = BottomSheetBehavior.STATE_EXPANDED
this.selectedSnapPoint = 2 this.selectedSnapPoint = 2
} else { } else {
@@ -227,11 +244,11 @@ class BottomSheetView(
bottomSheet?.let { bottomSheet?.let {
val behavior = BottomSheetBehavior.from(it) val behavior = BottomSheetBehavior.from(it)
behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight) behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
if (contentHeight > this.screenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) { if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (contentHeight < this.screenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) { } else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
} }
} }
@@ -1,14 +1,17 @@
import * as React from 'react' import * as React from 'react'
import { import {
Dimensions, Dimensions,
LayoutChangeEvent,
NativeSyntheticEvent, NativeSyntheticEvent,
Platform, Platform,
StyleProp, StyleProp,
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {isIOS} from '#/platform/detection'
import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types' import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types'
import {BottomSheetPortalProvider} from './BottomSheetPortal' import {BottomSheetPortalProvider} from './BottomSheetPortal'
import {Context as PortalContext} from './BottomSheetPortal' import {Context as PortalContext} from './BottomSheetPortal'
@@ -81,12 +84,10 @@ export class BottomSheetNativeComponent extends React.Component<
return null return null
} }
const {children, backgroundColor, ...rest} = this.props
const cornerRadius = rest.cornerRadius ?? 0
let extraStyles let extraStyles
if (isIOS15 && this.state.viewHeight) { if (isIOS15 && this.state.viewHeight) {
const {viewHeight} = this.state const {viewHeight} = this.state
const cornerRadius = this.props.cornerRadius ?? 0
if (viewHeight < screenHeight / 2) { if (viewHeight < screenHeight / 2) {
extraStyles = { extraStyles = {
height: viewHeight, height: viewHeight,
@@ -99,39 +100,70 @@ export class BottomSheetNativeComponent extends React.Component<
return ( return (
<Portal> <Portal>
<NativeView <BottomSheetNativeComponentInner
{...rest} {...this.props}
nativeViewRef={this.ref}
onStateChange={this.onStateChange} onStateChange={this.onStateChange}
ref={this.ref} extraStyles={extraStyles}
style={{ onLayout={e => {
position: 'absolute', const {height} = e.nativeEvent.layout
height: screenHeight, this.setState({viewHeight: height})
width: '100%', this.updateLayout()
}} }}
containerBackgroundColor={backgroundColor}> />
<View
style={[
{
flex: 1,
backgroundColor,
},
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
},
extraStyles,
]}>
<View
onLayout={e => {
const {height} = e.nativeEvent.layout
this.setState({viewHeight: height})
this.updateLayout()
}}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View>
</View>
</NativeView>
</Portal> </Portal>
) )
} }
} }
function BottomSheetNativeComponentInner({
children,
backgroundColor,
onLayout,
onStateChange,
nativeViewRef,
extraStyles,
...rest
}: BottomSheetViewProps & {
extraStyles?: StyleProp<ViewStyle>
onStateChange: (
event: NativeSyntheticEvent<{state: BottomSheetState}>,
) => void
nativeViewRef: React.RefObject<View>
onLayout: (event: LayoutChangeEvent) => void
}) {
const insets = useSafeAreaInsets()
const cornerRadius = rest.cornerRadius ?? 0
const sheetHeight = isIOS ? screenHeight - insets.top : screenHeight
return (
<NativeView
{...rest}
onStateChange={onStateChange}
ref={nativeViewRef}
style={{
position: 'absolute',
height: sheetHeight,
width: '100%',
}}
containerBackgroundColor={backgroundColor}>
<View
style={[
{
flex: 1,
backgroundColor,
},
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
},
extraStyles,
]}>
<View onLayout={onLayout}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View>
</View>
</NativeView>
)
}
+8 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.98.0", "version": "1.99.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@@ -42,8 +42,9 @@
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand \"yarn perf:test\" --duration 150000 --resultsFilePath .perf/results.json", "perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand \"yarn perf:test\" --duration 150000 --resultsFilePath .perf/results.json",
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json", "perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
"perf:measure": "NODE_ENV=test flashlight measure", "perf:measure": "NODE_ENV=test flashlight measure",
"intl:build": "yarn intl:extract && yarn intl:compile", "intl:build": "yarn intl:extract:all && yarn intl:compile",
"intl:extract": "lingui extract --clean --locale en", "intl:extract": "lingui extract --clean --locale en",
"intl:extract:all": "lingui extract --clean",
"intl:compile": "lingui compile", "intl:compile": "lingui compile",
"intl:pull": "crowdin download translations --verbose -b main", "intl:pull": "crowdin download translations --verbose -b main",
"intl:push": "crowdin push translations --verbose -b main", "intl:push": "crowdin push translations --verbose -b main",
@@ -56,7 +57,7 @@
"icons:optimize": "svgo -f ./assets/icons" "icons:optimize": "svgo -f ./assets/icons"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.13.35", "@atproto/api": "^0.14.7",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -78,10 +79,10 @@
"@mattermost/react-native-paste-input": "^0.7.1", "@mattermost/react-native-paste-input": "^0.7.1",
"@miblanchard/react-native-slider": "^2.3.1", "@miblanchard/react-native-slider": "^2.3.1",
"@mozzius/expo-dynamic-app-icon": "^1.5.0", "@mozzius/expo-dynamic-app-icon": "^1.5.0",
"@radix-ui/react-dismissable-layer": "^1.1.1", "@radix-ui/react-dismissable-layer": "^1.1.5",
"@radix-ui/react-dropdown-menu": "2.0.1", "@radix-ui/react-dropdown-menu": "2.1.6",
"@radix-ui/react-focus-guards": "^1.1.1", "@radix-ui/react-focus-guards": "^1.1.1",
"@radix-ui/react-focus-scope": "^1.1.0", "@radix-ui/react-focus-scope": "^1.1.2",
"@react-native-async-storage/async-storage": "1.23.1", "@react-native-async-storage/async-storage": "1.23.1",
"@react-native-menu/menu": "^1.1.7", "@react-native-menu/menu": "^1.1.7",
"@react-native-picker/picker": "2.10.2", "@react-native-picker/picker": "2.10.2",
@@ -211,7 +212,7 @@
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@atproto/dev-env": "^0.3.67", "@atproto/dev-env": "^0.3.87",
"@babel/core": "^7.26.0", "@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0", "@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0", "@babel/runtime": "^7.26.0",
@@ -270,7 +271,6 @@
"@expo/image-utils": "0.6.3", "@expo/image-utils": "0.6.3",
"@react-native/babel-preset": "0.76.1", "@react-native/babel-preset": "0.76.1",
"@react-native/normalize-colors": "0.76.1", "@react-native/normalize-colors": "0.76.1",
"@radix-ui/react-focus-scope": "1.1.0",
"@types/react": "^18", "@types/react": "^18",
"**/expo-constants": "17.0.3", "**/expo-constants": "17.0.3",
"**/expo-device": "7.0.1", "**/expo-device": "7.0.1",
+70
View File
@@ -0,0 +1,70 @@
const {withAppBuildGradle} = require('@expo/config-plugins')
/**
* A Config Plugin to disable bundle compression in Android build.gradle.
* @param {import('@expo/config-plugins').ConfigPlugin} config
* @returns {import('@expo/config-plugins').ConfigPlugin}
*/
module.exports = function withNoBundleCompression(config) {
return withAppBuildGradle(config, androidConfig => {
let buildGradle = androidConfig.modResults.contents
const hasAndroidResources = buildGradle.includes('androidResources {')
const hasNoCompress = buildGradle.includes('noCompress')
if (hasAndroidResources) {
if (hasNoCompress) {
if (
buildGradle.includes('noCompress += ["bundle"]') ||
buildGradle.includes("noCompress += 'bundle'") ||
buildGradle.includes('noCompress += "bundle"')
) {
return androidConfig
}
const lines = buildGradle.split('\n')
const modifiedLines = lines.map(line => {
if (line.trim().startsWith('noCompress')) {
if (line.includes('+=')) {
return line.replace(/\]/, ', "bundle"]')
} else if (line.includes('=')) {
return line.replace('=', '+= ["bundle",') + ']'
}
}
return line
})
androidConfig.modResults.contents = modifiedLines.join('\n')
} else {
const androidResources = buildGradle.indexOf('androidResources {')
if (androidResources === -1) {
throw new Error(
`Cannot find androidResources { block in build.gradle!`,
)
}
const insertPosition = buildGradle.indexOf('\n', androidResources) + 1
const newContent =
buildGradle.slice(0, insertPosition) +
' noCompress += ["bundle"]\n' +
buildGradle.slice(insertPosition)
androidConfig.modResults.contents = newContent
}
} else {
const androidBlock = buildGradle.indexOf('android {')
if (androidBlock === -1) {
throw new Error(`Cannot find android { block in build.gradle!`)
}
const insertPosition = buildGradle.indexOf('\n', androidBlock) + 1
const newContent =
buildGradle.slice(0, insertPosition) +
' androidResources {\n' +
' noCompress += ["bundle"]\n' +
' }\n' +
buildGradle.slice(insertPosition)
androidConfig.modResults.contents = newContent
}
return androidConfig
})
}
+3 -3
View File
@@ -1,6 +1,6 @@
import 'react-native-url-polyfill/auto' import 'react-native-url-polyfill/auto'
import '#/lib/sentry' // must be near top import '#/logger/sentry/setup'
import '#/lib/bitdrift' // must be near top import '#/logger/bitdrift/setup'
import '#/view/icons' import '#/view/icons'
import React, {useEffect, useState} from 'react' import React, {useEffect, useState} from 'react'
@@ -118,7 +118,7 @@ function InnerApp() {
useEffect(() => { useEffect(() => {
return listenSessionDropped(() => { return listenSessionDropped(() => {
Toast.show( Toast.show(
_(msg`Sorry! Your session expired. Please log in again.`), _(msg`Sorry! Your session expired. Please sign in again.`),
'info', 'info',
) )
}) })
+2 -2
View File
@@ -1,4 +1,4 @@
import '#/lib/sentry' // must be near top import '#/logger/sentry/setup' // must be near top
import '#/view/icons' import '#/view/icons'
import './style.css' import './style.css'
@@ -94,7 +94,7 @@ function InnerApp() {
useEffect(() => { useEffect(() => {
return listenSessionDropped(() => { return listenSessionDropped(() => {
Toast.show( Toast.show(
_(msg`Sorry! Your session expired. Please log in again.`), _(msg`Sorry! Your session expired. Please sign in again.`),
'info', 'info',
) )
}) })
+20 -4
View File
@@ -32,6 +32,7 @@ import {
import {RouteParams, State} from '#/lib/routes/types' import {RouteParams, State} from '#/lib/routes/types'
import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
import {bskyTitle} from '#/lib/strings/headings' import {bskyTitle} from '#/lib/strings/headings'
import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection' import {isNative, isWeb} from '#/platform/detection'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useUnreadNotifications} from '#/state/queries/notifications/unread'
@@ -68,6 +69,7 @@ import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTest
import HashtagScreen from '#/screens/Hashtag' import HashtagScreen from '#/screens/Hashtag'
import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation' import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation' import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings' import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
@@ -91,6 +93,7 @@ import {VideoFeed} from '#/screens/VideoFeed'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import {router} from '#/routes' import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army' import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {ProfileSearchScreen} from './screens/Profile/ProfileSearch'
import {AboutSettingsScreen} from './screens/Settings/AboutSettings' import {AboutSettingsScreen} from './screens/Settings/AboutSettings'
import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings' import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings'
import {AccountSettingsScreen} from './screens/Settings/AccountSettings' import {AccountSettingsScreen} from './screens/Settings/AccountSettings'
@@ -207,6 +210,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => ProfileListScreen} getComponent={() => ProfileListScreen}
options={{title: title(msg`List`), requireAuth: true}} options={{title: title(msg`List`), requireAuth: true}}
/> />
<Stack.Screen
name="ProfileSearch"
getComponent={() => ProfileSearchScreen}
options={({route}) => ({
title: title(msg`Search @${route.params.name}'s posts`),
})}
/>
<Stack.Screen <Stack.Screen
name="PostThread" name="PostThread"
getComponent={() => PostThreadScreen} getComponent={() => PostThreadScreen}
@@ -402,6 +412,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => MessagesSettingsScreen} getComponent={() => MessagesSettingsScreen}
options={{title: title(msg`Chat settings`), requireAuth: true}} options={{title: title(msg`Chat settings`), requireAuth: true}}
/> />
<Stack.Screen
name="MessagesInbox"
getComponent={() => MessagesInboxScreen}
options={{title: title(msg`Chat request inbox`), requireAuth: true}}
/>
<Stack.Screen <Stack.Screen
name="NotificationSettings" name="NotificationSettings"
getComponent={() => NotificationSettingsScreen} getComponent={() => NotificationSettingsScreen}
@@ -721,15 +736,16 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
linking={LINKING} linking={LINKING}
theme={theme} theme={theme}
onStateChange={() => { onStateChange={() => {
const routeName = getCurrentRouteName() logger.metric('router:navigate', {
if (routeName === 'Notifications') { from: prevLoggedRouteName.current,
logEvent('router:navigate:notifications', {}) })
} prevLoggedRouteName.current = getCurrentRouteName()
}} }}
onReady={() => { onReady={() => {
attachRouteToLogEvents(getCurrentRouteName) attachRouteToLogEvents(getCurrentRouteName)
logModuleInitTime() logModuleInitTime()
onReady() onReady()
logger.metric('router:navigate', {})
}}> }}>
{children} {children}
</NavigationContainer> </NavigationContainer>
+15
View File
@@ -31,6 +31,18 @@ export const atoms = {
right: 0, right: 0,
bottom: 0, bottom: 0,
}, },
top_0: {
top: 0,
},
right_0: {
right: 0,
},
bottom_0: {
bottom: 0,
},
left_0: {
left: 0,
},
z_10: { z_10: {
zIndex: 10, zIndex: 10,
}, },
@@ -93,6 +105,9 @@ export const atoms = {
/* /*
* Border radius * Border radius
*/ */
rounded_0: {
borderRadius: 0,
},
rounded_2xs: { rounded_2xs: {
borderRadius: tokens.borderRadius._2xs, borderRadius: tokens.borderRadius._2xs,
}, },
+15
View File
@@ -26,3 +26,18 @@ export function useBreakpoints(): Record<Breakpoint, boolean> & {
} }
}, [gtPhone, gtMobile, gtTablet]) }, [gtPhone, gtMobile, gtTablet])
} }
/**
* Fine-tuned breakpoints for the shell layout
*/
export function useLayoutBreakpoints() {
const rightNavVisible = useMediaQuery({minWidth: 1075})
const centerColumnOffset = useMediaQuery({minWidth: 1075, maxWidth: 1300})
const leftNavMinimal = useMediaQuery({maxWidth: 1300})
return {
rightNavVisible,
centerColumnOffset,
leftNavMinimal,
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ export function AccountList({
testID="chooseAddAccountBtn" testID="chooseAddAccountBtn"
style={[a.flex_1]} style={[a.flex_1]}
onPress={pendingDid ? undefined : onPressAddAccount} onPress={pendingDid ? undefined : onPressAddAccount}
label={_(msg`Login to account that is not listed`)}> label={_(msg`Sign in to account that is not listed`)}>
{({hovered, pressed}) => ( {({hovered, pressed}) => (
<View <View
style={[ style={[
+18 -10
View File
@@ -2,6 +2,7 @@ import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native' import {StyleProp, View, ViewStyle} from 'react-native'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button as BaseButton, ButtonProps} from '#/components/Button'
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye' import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye'
import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf' import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf'
@@ -49,22 +50,29 @@ export function Text({
return ( return (
<BaseText <BaseText
{...rest} {...rest}
style={[ style={[a.flex_1, a.text_sm, a.leading_snug, a.pr_md, style]}>
a.flex_1,
a.text_sm,
a.leading_snug,
{
paddingTop: 1,
},
style,
]}>
{children} {children}
</BaseText> </BaseText>
) )
} }
export function Button({
children,
...props
}: Omit<ButtonProps, 'size' | 'variant' | 'color'>) {
return (
<BaseButton size="tiny" variant="outline" color="secondary" {...props}>
{children}
</BaseButton>
)
}
export function Row({children}: {children: React.ReactNode}) { export function Row({children}: {children: React.ReactNode}) {
return <View style={[a.flex_row, a.gap_sm]}>{children}</View> return (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
{children}
</View>
)
} }
export function Outer({ export function Outer({
+41 -11
View File
@@ -1,37 +1,37 @@
import {View} from 'react-native' import {View} from 'react-native'
import {moderateProfile} from '@atproto/api' import {moderateProfile} from '@atproto/api'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfilesQuery} from '#/state/queries/profile' import {useProfilesQuery} from '#/state/queries/profile'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import * as bsky from '#/types/bsky'
export function AvatarStack({ export function AvatarStack({
profiles, profiles,
size = 26, size = 26,
numPending,
backgroundColor,
}: { }: {
profiles: string[] profiles: bsky.profile.AnyProfileView[]
size?: number size?: number
numPending?: number
backgroundColor?: string
}) { }) {
const halfSize = size / 2 const halfSize = size / 2
const {data, error} = useProfilesQuery({handles: profiles})
const t = useTheme() const t = useTheme()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
if (error) { const isPending = (numPending && profiles.length === 0) || !moderationOpts
console.error(error)
return null
}
const isPending = !data || !moderationOpts
const items = isPending const items = isPending
? Array.from({length: profiles.length}).map((_, i) => ({ ? Array.from({length: numPending ?? profiles.length}).map((_, i) => ({
key: i, key: i,
profile: null, profile: null,
moderation: null, moderation: null,
})) }))
: data.profiles.map(item => ({ : profiles.map(item => ({
key: item.did, key: item.did,
profile: item, profile: item,
moderation: moderateProfile(item, moderationOpts), moderation: moderateProfile(item, moderationOpts),
@@ -56,7 +56,7 @@ export function AvatarStack({
height: size, height: size,
left: i * -halfSize, left: i * -halfSize,
borderWidth: 1, borderWidth: 1,
borderColor: t.atoms.bg.backgroundColor, borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
borderRadius: 999, borderRadius: 999,
zIndex: 3 - i, zIndex: 3 - i,
}, },
@@ -74,3 +74,33 @@ export function AvatarStack({
</View> </View>
) )
} }
export function AvatarStackWithFetch({
profiles,
size,
backgroundColor,
}: {
profiles: string[]
size?: number
backgroundColor?: string
}) {
const {data, error} = useProfilesQuery({handles: profiles})
if (error) {
if (error.name !== 'AbortError') {
logger.error('Error fetching profiles for AvatarStack', {
safeMessage: error,
})
}
return null
}
return (
<AvatarStack
numPending={profiles.length}
profiles={data?.profiles || []}
size={size}
backgroundColor={backgroundColor}
/>
)
}
+1
View File
@@ -14,6 +14,7 @@ export const Context = React.createContext<DialogContextProps>({
nativeSnapPoint: BottomSheetSnapPoint.Hidden, nativeSnapPoint: BottomSheetSnapPoint.Hidden,
disableDrag: false, disableDrag: false,
setDisableDrag: () => {}, setDisableDrag: () => {},
isWithinDialog: false,
}) })
export function useDialogContext() { export function useDialogContext() {
+23 -13
View File
@@ -26,7 +26,7 @@ import {isAndroid, isIOS} from '#/platform/detection'
import {useA11y} from '#/state/a11y' import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs' import {useDialogStateControlContext} from '#/state/dialogs'
import {List, ListMethods, ListProps} from '#/view/com/util/List' import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, tokens, useTheme} from '#/alf'
import {useThemeName} from '#/alf/util/useColorModeTheme' import {useThemeName} from '#/alf/util/useColorModeTheme'
import {Context, useDialogContext} from '#/components/Dialog/context' import {Context, useDialogContext} from '#/components/Dialog/context'
import { import {
@@ -46,7 +46,7 @@ export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/shared' export * from '#/components/Dialog/shared'
export * from '#/components/Dialog/types' export * from '#/components/Dialog/types'
export * from '#/components/Dialog/utils' export * from '#/components/Dialog/utils'
// @ts-ignore
export const Input = createInput(TextInput) export const Input = createInput(TextInput)
export function Outer({ export function Outer({
@@ -154,6 +154,7 @@ export function Outer({
nativeSnapPoint: snapPoint, nativeSnapPoint: snapPoint,
disableDrag, disableDrag,
setDisableDrag, setDisableDrag,
isWithinDialog: true,
}), }),
[close, snapPoint, disableDrag, setDisableDrag], [close, snapPoint, disableDrag, setDisableDrag],
) )
@@ -168,7 +169,9 @@ export function Outer({
onStateChange={onStateChange} onStateChange={onStateChange}
disableDrag={disableDrag}> disableDrag={disableDrag}>
<Context.Provider value={context}> <Context.Provider value={context}>
<View testID={testID}>{children}</View> <View testID={testID} style={[a.relative]}>
{children}
</View>
</Context.Provider> </Context.Provider>
</BottomSheet> </BottomSheet>
) )
@@ -196,7 +199,7 @@ export function Inner({children, style, header}: DialogInnerProps) {
export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>( export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
function ScrollableInner( function ScrollableInner(
{children, style, contentContainerStyle, header, ...props}, {children, contentContainerStyle, header, ...props},
ref, ref,
) { ) {
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
@@ -216,13 +219,21 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
[], [],
) )
const basePading = let paddingBottom = 0
(isIOS ? 30 : 50) + (isIOS ? keyboardHeight / 4 : keyboardHeight) if (isIOS) {
const fullPaddingBase = insets.bottom + insets.top + basePading paddingBottom += keyboardHeight / 4
const fullPadding = isIOS ? fullPaddingBase : fullPaddingBase + 50 if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
paddingBottom += insets.bottom + tokens.space.md
const paddingBottom = }
nativeSnapPoint === BottomSheetSnapPoint.Full ? fullPadding : basePading paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
} else {
paddingBottom += keyboardHeight
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
paddingBottom += insets.top
}
paddingBottom +=
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
}
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => { const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!isAndroid) { if (!isAndroid) {
@@ -238,7 +249,6 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
return ( return (
<KeyboardAwareScrollView <KeyboardAwareScrollView
style={[style]}
contentContainerStyle={[ contentContainerStyle={[
a.pt_2xl, a.pt_2xl,
a.px_xl, a.px_xl,
@@ -316,7 +326,7 @@ export function Handle() {
style={[ style={[
a.rounded_sm, a.rounded_sm,
{ {
top: 10, top: tokens.space._2xl / 2 - 2.5,
width: 35, width: 35,
height: 5, height: 5,
alignSelf: 'center', alignSelf: 'center',
+16 -8
View File
@@ -41,6 +41,7 @@ export function Outer({
children, children,
control, control,
onClose, onClose,
webOptions,
}: React.PropsWithChildren<DialogOuterProps>) { }: React.PropsWithChildren<DialogOuterProps>) {
const {_} = useLingui() const {_} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
@@ -96,6 +97,7 @@ export function Outer({
nativeSnapPoint: 0, nativeSnapPoint: 0,
disableDrag: false, disableDrag: false,
setDisableDrag: () => {}, setDisableDrag: () => {},
isWithinDialog: true,
}), }),
[close], [close],
) )
@@ -115,20 +117,26 @@ export function Outer({
web(a.fixed), web(a.fixed),
a.inset_0, a.inset_0,
a.z_10, a.z_10,
a.px_xl,
webOptions?.alignCenter ? a.justify_center : undefined,
a.align_center, a.align_center,
gtMobile ? a.p_lg : a.p_md, {
{overflowY: 'auto'}, overflowY: 'auto',
paddingVertical: gtMobile ? '10vh' : a.pt_xl.paddingTop,
},
]}> ]}>
<Backdrop /> <Backdrop />
{/**
* This is needed to prevent centered dialogs from overflowing
* above the screen, and provides a "natural" centering so that
* stacked dialogs appear relatively aligned.
*/}
<View <View
style={[ style={[
a.w_full, a.w_full,
a.z_20, a.z_20,
a.justify_center,
a.align_center, a.align_center,
{ web({minHeight: '60vh', position: 'static'}),
minHeight: web('calc(90vh - 36px)') || undefined,
},
]}> ]}>
{children} {children}
</View> </View>
@@ -217,8 +225,8 @@ export const InnerFlatList = React.forwardRef<
style={[ style={[
a.overflow_hidden, a.overflow_hidden,
a.px_0, a.px_0,
// @ts-expect-error web only -sfn // 100 minus 10vh of paddingVertical
{maxHeight: 'calc(-36px + 100vh)'}, web({maxHeight: '80vh'}),
webInnerStyle, webInnerStyle,
]} ]}
contentContainerStyle={[a.px_0, webInnerContentContainerStyle]}> contentContainerStyle={[a.px_0, webInnerContentContainerStyle]}>
+9 -2
View File
@@ -44,6 +44,8 @@ export type DialogContextProps = {
nativeSnapPoint: BottomSheetSnapPoint nativeSnapPoint: BottomSheetSnapPoint
disableDrag: boolean disableDrag: boolean
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>> setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
// in the event that the hook is used outside of a dialog
isWithinDialog: boolean
} }
export type DialogControlOpenOptions = { export type DialogControlOpenOptions = {
@@ -60,11 +62,16 @@ export type DialogOuterProps = {
control: DialogControlProps control: DialogControlProps
onClose?: () => void onClose?: () => void
nativeOptions?: Omit<BottomSheetViewProps, 'children'> nativeOptions?: Omit<BottomSheetViewProps, 'children'>
webOptions?: {} webOptions?: {
alignCenter?: boolean
}
testID?: string testID?: string
} }
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> &
T & {
testID?: string
}
export type DialogInnerProps = export type DialogInnerProps =
| DialogInnerPropsBase<{ | DialogInnerPropsBase<{
label?: undefined label?: undefined
+5 -5
View File
@@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import {GestureResponderEvent, View} from 'react-native' import {GestureResponderEvent, View} from 'react-native'
import { import {
AppBskyActorDefs,
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyGraphDefs, AppBskyGraphDefs,
AtUri, AtUri,
@@ -32,6 +31,7 @@ import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {RichText, RichTextProps} from '#/components/RichText' import {RichText, RichTextProps} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
type Props = { type Props = {
view: AppBskyFeedDefs.GeneratorView view: AppBskyFeedDefs.GeneratorView
@@ -115,7 +115,7 @@ export function TitleAndByline({
creator, creator,
}: { }: {
title: string title: string
creator?: AppBskyActorDefs.ProfileViewBasic creator?: bsky.profile.AnyProfileView
}) { }) {
const t = useTheme() const t = useTheme()
@@ -269,9 +269,9 @@ function SaveButtonInner({
}, },
]) ])
} }
Toast.show(_(msg`Feeds updated!`)) Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'})))
} catch (err: any) { } catch (err: any) {
logger.error(err, {context: `FeedCard: failed to update feeds`, pin}) logger.error(err, {message: `FeedCard: failed to update feeds`, pin})
Toast.show(_(msg`Failed to update feeds`), 'xmark') Toast.show(_(msg`Failed to update feeds`), 'xmark')
} }
}, },
@@ -307,7 +307,7 @@ function SaveButtonInner({
<Prompt.Basic <Prompt.Basic
control={removePromptControl} control={removePromptControl}
title={_(msg`Remove from my feeds?`)} title={_(msg`Remove from your feeds?`)}
description={_( description={_(
msg`Are you sure you want to remove this from your feeds?`, msg`Are you sure you want to remove this from your feeds?`,
)} )}
+5 -3
View File
@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import {ScrollView, View} from 'react-native' import {View} from 'react-native'
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
@@ -26,6 +27,7 @@ import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/P
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import {ProgressGuideList} from './ProgressGuide/List' import {ProgressGuideList} from './ProgressGuide/List'
const MOBILE_CARD_WIDTH = 300 const MOBILE_CARD_WIDTH = 300
@@ -227,7 +229,7 @@ export function ProfileGrid({
viewContext = 'feed', viewContext = 'feed',
}: { }: {
isSuggestionsLoading: boolean isSuggestionsLoading: boolean
profiles: AppBskyActorDefs.ProfileViewDetailed[] profiles: bsky.profile.AnyProfileView[]
recId?: number recId?: number
error: Error | null error: Error | null
viewContext: 'profile' | 'feed' viewContext: 'profile' | 'feed'
+23 -11
View File
@@ -10,6 +10,7 @@ import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Link, LinkProps} from '#/components/Link' import {Link, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
const AVI_SIZE = 30 const AVI_SIZE = 30
const AVI_SIZE_SMALL = 20 const AVI_SIZE_SMALL = 20
@@ -32,11 +33,13 @@ export function KnownFollowers({
moderationOpts, moderationOpts,
onLinkPress, onLinkPress,
minimal, minimal,
showIfEmpty,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
onLinkPress?: LinkProps['onPress'] onLinkPress?: LinkProps['onPress']
minimal?: boolean minimal?: boolean
showIfEmpty?: boolean
}) { }) {
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>( const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
new Map(), new Map(),
@@ -63,11 +66,12 @@ export function KnownFollowers({
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
onLinkPress={onLinkPress} onLinkPress={onLinkPress}
minimal={minimal} minimal={minimal}
showIfEmpty={showIfEmpty}
/> />
) )
} }
return null return <EmptyFallback show={showIfEmpty} />
} }
function KnownFollowersInner({ function KnownFollowersInner({
@@ -76,22 +80,19 @@ function KnownFollowersInner({
cachedKnownFollowers, cachedKnownFollowers,
onLinkPress, onLinkPress,
minimal, minimal,
showIfEmpty,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
onLinkPress?: LinkProps['onPress'] onLinkPress?: LinkProps['onPress']
minimal?: boolean minimal?: boolean
showIfEmpty?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const textStyle = [ const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]
a.flex_1,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]
const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => { const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
const moderation = moderateProfile(f, moderationOpts) const moderation = moderateProfile(f, moderationOpts)
@@ -114,7 +115,7 @@ function KnownFollowersInner({
* We check above too, but here for clarity and a reminder to _check for * We check above too, but here for clarity and a reminder to _check for
* valid indices_ * valid indices_
*/ */
if (slice.length === 0) return null if (slice.length === 0) return <EmptyFallback show={showIfEmpty} />
const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
@@ -126,7 +127,6 @@ function KnownFollowersInner({
onPress={onLinkPress} onPress={onLinkPress}
to={makeProfileLink(profile, 'known-followers')} to={makeProfileLink(profile, 'known-followers')}
style={[ style={[
a.flex_1,
a.flex_row, a.flex_row,
minimal ? a.gap_sm : a.gap_md, minimal ? a.gap_sm : a.gap_md,
a.align_center, a.align_center,
@@ -242,3 +242,15 @@ function KnownFollowersInner({
</Link> </Link>
) )
} }
function EmptyFallback({show}: {show?: boolean}) {
const t = useTheme()
if (!show) return null
return (
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>Not followed by anyone you're following</Trans>
</Text>
)
}
+9 -1
View File
@@ -14,6 +14,7 @@ import {
TextStyleProp, TextStyleProp,
useBreakpoints, useBreakpoints,
useGutters, useGutters,
useLayoutBreakpoints,
useTheme, useTheme,
web, web,
} from '#/alf' } from '#/alf'
@@ -23,6 +24,7 @@ import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import { import {
BUTTON_VISUAL_ALIGNMENT_OFFSET, BUTTON_VISUAL_ALIGNMENT_OFFSET,
HEADER_SLOT_SIZE, HEADER_SLOT_SIZE,
SCROLLBAR_OFFSET,
} from '#/components/Layout/const' } from '#/components/Layout/const'
import {ScrollbarOffsetContext} from '#/components/Layout/context' import {ScrollbarOffsetContext} from '#/components/Layout/context'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -42,6 +44,7 @@ export function Outer({
const gutters = useGutters([0, 'base']) const gutters = useGutters([0, 'base'])
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {isWithinOffsetView} = useContext(ScrollbarOffsetContext) const {isWithinOffsetView} = useContext(ScrollbarOffsetContext)
const {centerColumnOffset} = useLayoutBreakpoints()
return ( return (
<View <View
@@ -60,7 +63,12 @@ export function Outer({
}), }),
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
gtMobile && [a.mx_auto, {maxWidth: 600}], gtMobile && [a.mx_auto, {maxWidth: 600}],
!isWithinOffsetView && a.scrollbar_offset, !isWithinOffsetView && {
transform: [
{translateX: centerColumnOffset ? -150 : 0},
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
],
},
]}> ]}>
{children} {children}
</View> </View>
+35 -8
View File
@@ -13,7 +13,15 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {
atoms as a,
useBreakpoints,
useLayoutBreakpoints,
useTheme,
web,
} from '#/alf'
import {useDialogContext} from '#/components/Dialog'
import {SCROLLBAR_OFFSET} from '#/components/Layout/const'
import {ScrollbarOffsetContext} from '#/components/Layout/context' import {ScrollbarOffsetContext} from '#/components/Layout/context'
export * from '#/components/Layout/const' export * from '#/components/Layout/const'
@@ -47,6 +55,7 @@ export const Screen = React.memo(function Screen({
export type ContentProps = AnimatedScrollViewProps & { export type ContentProps = AnimatedScrollViewProps & {
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
contentContainerStyle?: StyleProp<ViewStyle> contentContainerStyle?: StyleProp<ViewStyle>
ignoreTabletLayoutOffset?: boolean
} }
/** /**
@@ -56,6 +65,7 @@ export const Content = React.memo(function Content({
children, children,
style, style,
contentContainerStyle, contentContainerStyle,
ignoreTabletLayoutOffset,
...props ...props
}: ContentProps) { }: ContentProps) {
const t = useTheme() const t = useTheme()
@@ -84,8 +94,10 @@ export const Content = React.memo(function Content({
]} ]}
{...props}> {...props}>
{isWeb ? ( {isWeb ? (
// @ts-ignore web only -esb <Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
<Center>{children}</Center> {/* @ts-expect-error web only -esb */}
{children}
</Center>
) : ( ) : (
children children
)} )}
@@ -138,10 +150,13 @@ export const KeyboardAwareContent = React.memo(function LayoutScrollView({
export const Center = React.memo(function LayoutContent({ export const Center = React.memo(function LayoutContent({
children, children,
style, style,
ignoreTabletLayoutOffset,
...props ...props
}: ViewProps) { }: ViewProps & {ignoreTabletLayoutOffset?: boolean}) {
const {isWithinOffsetView} = useContext(ScrollbarOffsetContext) const {isWithinOffsetView} = useContext(ScrollbarOffsetContext)
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {centerColumnOffset} = useLayoutBreakpoints()
const {isWithinDialog} = useDialogContext()
const ctx = useMemo(() => ({isWithinOffsetView: true}), []) const ctx = useMemo(() => ({isWithinOffsetView: true}), [])
return ( return (
<View <View
@@ -151,8 +166,20 @@ export const Center = React.memo(function LayoutContent({
gtMobile && { gtMobile && {
maxWidth: 600, maxWidth: 600,
}, },
!isWithinOffsetView && {
transform: [
{
translateX:
centerColumnOffset &&
!ignoreTabletLayoutOffset &&
!isWithinDialog
? -150
: 0,
},
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
],
},
style, style,
!isWithinOffsetView && a.scrollbar_offset,
]} ]}
{...props}> {...props}>
<ScrollbarOffsetContext.Provider value={ctx}> <ScrollbarOffsetContext.Provider value={ctx}>
@@ -168,6 +195,7 @@ export const Center = React.memo(function LayoutContent({
const WebCenterBorders = React.memo(function LayoutContent() { const WebCenterBorders = React.memo(function LayoutContent() {
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {centerColumnOffset} = useLayoutBreakpoints()
return gtMobile ? ( return gtMobile ? (
<View <View
style={[ style={[
@@ -180,9 +208,8 @@ const WebCenterBorders = React.memo(function LayoutContent() {
width: 602, width: 602,
left: '50%', left: '50%',
transform: [ transform: [
{ {translateX: '-50%'},
translateX: '-50%', {translateX: centerColumnOffset ? -150 : 0},
},
...a.scrollbar_offset.transform, ...a.scrollbar_offset.transform,
], ],
}), }),
+13 -1
View File
@@ -69,6 +69,11 @@ type BaseLinkProps = Pick<
* Native-only attribute. If true, will open the share sheet on long press. * Native-only attribute. If true, will open the share sheet on long press.
*/ */
shareOnLongPress?: boolean shareOnLongPress?: boolean
/**
* Whether the link should be opened through the redirect proxy.
*/
shouldProxy?: boolean
} }
export function useLink({ export function useLink({
@@ -80,9 +85,11 @@ export function useLink({
onLongPress: outerOnLongPress, onLongPress: outerOnLongPress,
shareOnLongPress, shareOnLongPress,
overridePresentation, overridePresentation,
shouldProxy,
}: BaseLinkProps & { }: BaseLinkProps & {
displayText: string displayText: string
overridePresentation?: boolean overridePresentation?: boolean
shouldProxy?: boolean
}) { }) {
const navigation = useNavigationDeduped() const navigation = useNavigationDeduped()
const {href} = useLinkProps<AllNavigatorParams>({ const {href} = useLinkProps<AllNavigatorParams>({
@@ -118,7 +125,7 @@ export function useLink({
}) })
} else { } else {
if (isExternal) { if (isExternal) {
openLink(href, overridePresentation) openLink(href, overridePresentation, shouldProxy)
} else { } else {
const shouldOpenInNewTab = shouldClickOpenNewTab(e) const shouldOpenInNewTab = shouldClickOpenNewTab(e)
@@ -161,6 +168,7 @@ export function useLink({
action, action,
navigation, navigation,
overridePresentation, overridePresentation,
shouldProxy,
], ],
) )
@@ -219,6 +227,7 @@ export function Link({
onPress: outerOnPress, onPress: outerOnPress,
onLongPress: outerOnLongPress, onLongPress: outerOnLongPress,
download, download,
shouldProxy,
...rest ...rest
}: LinkProps) { }: LinkProps) {
const {href, isExternal, onPress, onLongPress} = useLink({ const {href, isExternal, onPress, onLongPress} = useLink({
@@ -227,6 +236,7 @@ export function Link({
action, action,
onPress: outerOnPress, onPress: outerOnPress,
onLongPress: outerOnLongPress, onLongPress: outerOnLongPress,
shouldProxy: shouldProxy,
}) })
return ( return (
@@ -279,6 +289,7 @@ export function InlineLinkText({
shareOnLongPress, shareOnLongPress,
disableUnderline, disableUnderline,
overridePresentation, overridePresentation,
shouldProxy,
...rest ...rest
}: InlineLinkProps) { }: InlineLinkProps) {
const t = useTheme() const t = useTheme()
@@ -292,6 +303,7 @@ export function InlineLinkText({
onLongPress: outerOnLongPress, onLongPress: outerOnLongPress,
shareOnLongPress, shareOnLongPress,
overridePresentation, overridePresentation,
shouldProxy: shouldProxy,
}) })
const { const {
state: hovered, state: hovered,
+2 -2
View File
@@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import { import {
AppBskyActorDefs,
AppBskyGraphDefs, AppBskyGraphDefs,
AtUri, AtUri,
moderateUserList, moderateUserList,
@@ -26,6 +25,7 @@ import {
import {Link as InternalLink, LinkProps} from '#/components/Link' import {Link as InternalLink, LinkProps} from '#/components/Link'
import * as Hider from '#/components/moderation/Hider' import * as Hider from '#/components/moderation/Hider'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
/* /*
* This component is based on `FeedCard` and is tightly coupled with that * This component is based on `FeedCard` and is tightly coupled with that
@@ -107,7 +107,7 @@ export function TitleAndByline({
modUi, modUi,
}: { }: {
title: string title: string
creator?: AppBskyActorDefs.ProfileViewBasic creator?: bsky.profile.AnyProfileView
purpose?: AppBskyGraphDefs.ListView['purpose'] purpose?: AppBskyGraphDefs.ListView['purpose']
modUi?: ModerationUI modUi?: ModerationUI
}) { }) {
+22 -36
View File
@@ -1,19 +1,15 @@
import React from 'react' import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import { import {AppBskyFeedDefs} from '@atproto/api'
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
AppBskyEmbedVideo,
} from '@atproto/api'
import {Trans} from '@lingui/macro' import {Trans} from '@lingui/macro'
import {parseTenorGif} from '#/lib/strings/embed-player' import {isTenorGifUri} from '#/lib/strings/embed-player'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import * as bsky from '#/types/bsky'
/** /**
* Streamlined MediaPreview component which just handles images, gifs, and videos * Streamlined MediaPreview component which just handles images, gifs, and videos
@@ -22,20 +18,17 @@ export function Embed({
embed, embed,
style, style,
}: { }: {
embed?: embed: AppBskyFeedDefs.PostView['embed']
| AppBskyEmbedImages.View
| AppBskyEmbedRecordWithMedia.View
| AppBskyEmbedExternal.View
| AppBskyEmbedVideo.View
| {[k: string]: unknown}
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
let media = AppBskyEmbedRecordWithMedia.isView(embed) ? embed.media : embed const e = bsky.post.parseEmbed(embed)
if (AppBskyEmbedImages.isView(media)) { if (!e) return null
if (e.type === 'images') {
return ( return (
<Outer style={style}> <Outer style={style}>
{media.images.map(image => ( {e.view.images.map(image => (
<ImageItem <ImageItem
key={image.thumb} key={image.thumb}
thumbnail={image.thumb} thumbnail={image.thumb}
@@ -44,28 +37,21 @@ export function Embed({
))} ))}
</Outer> </Outer>
) )
} else if (AppBskyEmbedExternal.isView(media) && media.external.thumb) { } else if (e.type === 'link') {
let url: URL | undefined if (!e.view.external.thumb) return null
try { if (!isTenorGifUri(e.view.external.uri)) return null
url = new URL(media.external.uri)
} catch {}
if (url) {
const {success} = parseTenorGif(url)
if (success) {
return (
<Outer style={style}>
<GifItem
thumbnail={media.external.thumb}
alt={media.external.title}
/>
</Outer>
)
}
}
} else if (AppBskyEmbedVideo.isView(media)) {
return ( return (
<Outer style={style}> <Outer style={style}>
<VideoItem thumbnail={media.thumbnail} alt={media.alt} /> <GifItem
thumbnail={e.view.external.thumb}
alt={e.view.external.title}
/>
</Outer>
)
} else if (e.type === 'video') {
return (
<Outer style={style}>
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
</Outer> </Outer>
) )
} }
+1 -1
View File
@@ -100,7 +100,7 @@ export function Outer({
<Dialog.Handle /> <Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */} {/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}> <Context.Provider value={context}>
<Dialog.ScrollableInner label={_(msg`Menu`)} style={[a.py_sm]}> <Dialog.ScrollableInner label={_(msg`Menu`)}>
<View style={[a.gap_lg]}> <View style={[a.gap_lg]}>
{children} {children}
{isNative && showCancel && <Cancel />} {isNative && showCancel && <Cancel />}
+1 -1
View File
@@ -187,7 +187,7 @@ export function Outer({
collisionPadding={{left: 5, right: 5, bottom: 5}} collisionPadding={{left: 5, right: 5, bottom: 5}}
loop loop
aria-label="Test" aria-label="Test"
className="dropdown-menu-transform-origin"> className="dropdown-menu-transform-origin dropdown-menu-constrain-size">
<View <View
style={[ style={[
a.rounded_sm, a.rounded_sm,
+11 -12
View File
@@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import {GestureResponderEvent, View} from 'react-native' import {GestureResponderEvent, View} from 'react-native'
import { import {
AppBskyActorDefs,
moderateProfile, moderateProfile,
ModerationOpts, ModerationOpts,
RichText as RichTextApi, RichText as RichTextApi,
@@ -25,13 +24,14 @@ import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus
import {Link as InternalLink, LinkProps} from '#/components/Link' import {Link as InternalLink, LinkProps} from '#/components/Link'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
export function Default({ export function Default({
profile, profile,
moderationOpts, moderationOpts,
logContext = 'ProfileCard', logContext = 'ProfileCard',
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList' logContext?: 'ProfileCard' | 'StarterPackProfilesList'
}) { }) {
@@ -51,7 +51,7 @@ export function Card({
moderationOpts, moderationOpts,
logContext = 'ProfileCard', logContext = 'ProfileCard',
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList' logContext?: 'ProfileCard' | 'StarterPackProfilesList'
}) { }) {
@@ -101,7 +101,7 @@ export function Link({
style, style,
...rest ...rest
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: bsky.profile.AnyProfileView
} & Omit<LinkProps, 'to' | 'label'>) { } & Omit<LinkProps, 'to' | 'label'>) {
const {_} = useLingui() const {_} = useLingui()
return ( return (
@@ -126,7 +126,7 @@ export function Avatar({
profile, profile,
moderationOpts, moderationOpts,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
}) { }) {
const moderation = moderateProfile(profile, moderationOpts) const moderation = moderateProfile(profile, moderationOpts)
@@ -161,7 +161,7 @@ export function NameAndHandle({
profile, profile,
moderationOpts, moderationOpts,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
}) { }) {
const t = useTheme() const t = useTheme()
@@ -224,17 +224,16 @@ export function Description({
profile: profileUnshadowed, profile: profileUnshadowed,
numberOfLines = 3, numberOfLines = 3,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: bsky.profile.AnyProfileView
numberOfLines?: number numberOfLines?: number
}) { }) {
const profile = useProfileShadow(profileUnshadowed) const profile = useProfileShadow(profileUnshadowed)
const {description} = profile
const rt = React.useMemo(() => { const rt = React.useMemo(() => {
if (!description) return if (!('description' in profile)) return
const rt = new RichTextApi({text: description || ''}) const rt = new RichTextApi({text: profile.description || ''})
rt.detectFacetsWithoutResolution() rt.detectFacetsWithoutResolution()
return rt return rt
}, [description]) }, [profile])
if (!rt) return null if (!rt) return null
if ( if (
profile.viewer && profile.viewer &&
@@ -281,7 +280,7 @@ export function DescriptionPlaceholder({
} }
export type FollowButtonProps = { export type FollowButtonProps = {
profile: AppBskyActorDefs.ProfileViewBasic profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
logContext: LogEvents['profile:follow']['logContext'] & logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext'] LogEvents['profile:unfollow']['logContext']
+1
View File
@@ -45,6 +45,7 @@ export function Outer({
<Dialog.Outer <Dialog.Outer
control={control} control={control}
testID={testID} testID={testID}
webOptions={{alignCenter: true}}
nativeOptions={{preventExpansion: true, ...nativeOptions}}> nativeOptions={{preventExpansion: true, ...nativeOptions}}>
<Dialog.Handle /> <Dialog.Handle />
<Context.Provider value={context}> <Context.Provider value={context}>
+4
View File
@@ -23,6 +23,7 @@ export type RichTextProps = TextStyleProp &
onLinkPress?: LinkProps['onPress'] onLinkPress?: LinkProps['onPress']
interactiveStyle?: TextStyle interactiveStyle?: TextStyle
emojiMultiplier?: number emojiMultiplier?: number
shouldProxyLinks?: boolean
} }
export function RichText({ export function RichText({
@@ -39,6 +40,7 @@ export function RichText({
emojiMultiplier = 1.85, emojiMultiplier = 1.85,
onLayout, onLayout,
onTextLayout, onTextLayout,
shouldProxyLinks,
}: RichTextProps) { }: RichTextProps) {
const richText = React.useMemo( const richText = React.useMemo(
() => () =>
@@ -110,6 +112,7 @@ export function RichText({
style={interactiveStyles} style={interactiveStyles}
// @ts-ignore TODO // @ts-ignore TODO
dataSet={WORD_WRAP} dataSet={WORD_WRAP}
shouldProxy={shouldProxyLinks}
onPress={onLinkPress}> onPress={onLinkPress}>
{segment.text} {segment.text}
</InlineLinkText> </InlineLinkText>
@@ -128,6 +131,7 @@ export function RichText({
// @ts-ignore TODO // @ts-ignore TODO
dataSet={WORD_WRAP} dataSet={WORD_WRAP}
shareOnLongPress shareOnLongPress
shouldProxy={shouldProxyLinks}
onPress={onLinkPress} onPress={onLinkPress}
emoji> emoji>
{toShortUrl(segment.text)} {toShortUrl(segment.text)}
@@ -1,4 +1,9 @@
import React from 'react' import React, {
useCallback,
useEffect,
useImperativeHandle,
useState,
} from 'react'
import { import {
findNodeHandle, findNodeHandle,
ListRenderItemInfo, ListRenderItemInfo,
@@ -6,11 +11,10 @@ import {
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {AppBskyGraphDefs, AppBskyGraphGetActorStarterPacks} from '@atproto/api' import {AppBskyGraphDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack' import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
@@ -19,28 +23,27 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types' import {NavigationProp} from '#/lib/routes/types'
import {parseStarterPackUri} from '#/lib/strings/starter-pack' import {parseStarterPackUri} from '#/lib/strings/starter-pack'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
import {List, ListRef} from '#/view/com/util/List' import {List, ListRef} from '#/view/com/util/List'
import {Text} from '#/view/com/util/text/Text' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, ios, useTheme} from '#/alf' import {atoms as a, ios, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {LinearGradientBackground} from '#/components/LinearGradientBackground'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog' import {Text} from '#/components/Typography'
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus'
interface SectionRef { interface SectionRef {
scrollToTop: () => void scrollToTop: () => void
} }
interface ProfileFeedgensProps { interface ProfileFeedgensProps {
starterPacksQuery: UseInfiniteQueryResult<
InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>,
Error
>
scrollElRef: ListRef scrollElRef: ListRef
did: string
headerOffset: number headerOffset: number
enabled?: boolean enabled?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
@@ -58,8 +61,8 @@ export const ProfileStarterPacks = React.forwardRef<
ProfileFeedgensProps ProfileFeedgensProps
>(function ProfileFeedgensImpl( >(function ProfileFeedgensImpl(
{ {
starterPacksQuery: query,
scrollElRef, scrollElRef,
did,
headerOffset, headerOffset,
enabled, enabled,
style, style,
@@ -71,17 +74,18 @@ export const ProfileStarterPacks = React.forwardRef<
) { ) {
const t = useTheme() const t = useTheme()
const bottomBarOffset = useBottomBarOffset(100) const bottomBarOffset = useBottomBarOffset(100)
const [isPTRing, setIsPTRing] = React.useState(false) const [isPTRing, setIsPTRing] = useState(false)
const {data, refetch, isFetching, hasNextPage, fetchNextPage} = query const {data, refetch, isFetching, hasNextPage, fetchNextPage} =
useActorStarterPacksQuery({did, enabled})
const {isTabletOrDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()
const items = data?.pages.flatMap(page => page.starterPacks) const items = data?.pages.flatMap(page => page.starterPacks)
React.useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
scrollToTop: () => {}, scrollToTop: () => {},
})) }))
const onRefresh = React.useCallback(async () => { const onRefresh = useCallback(async () => {
setIsPTRing(true) setIsPTRing(true)
try { try {
await refetch() await refetch()
@@ -91,7 +95,7 @@ export const ProfileStarterPacks = React.forwardRef<
setIsPTRing(false) setIsPTRing(false)
}, [refetch, setIsPTRing]) }, [refetch, setIsPTRing])
const onEndReached = React.useCallback(async () => { const onEndReached = useCallback(async () => {
if (isFetching || !hasNextPage) return if (isFetching || !hasNextPage) return
try { try {
@@ -101,7 +105,7 @@ export const ProfileStarterPacks = React.forwardRef<
} }
}, [isFetching, hasNextPage, fetchNextPage]) }, [isFetching, hasNextPage, fetchNextPage])
React.useEffect(() => { useEffect(() => {
if (enabled && scrollElRef.current) { if (enabled && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current) const nativeTag = findNodeHandle(scrollElRef.current)
setScrollViewTag(nativeTag) setScrollViewTag(nativeTag)
@@ -140,9 +144,11 @@ export const ProfileStarterPacks = React.forwardRef<
desktopFixedHeight desktopFixedHeight
onEndReached={onEndReached} onEndReached={onEndReached}
onRefresh={onRefresh} onRefresh={onRefresh}
ListEmptyComponent={Empty} ListEmptyComponent={
data ? (isMe ? Empty : undefined) : FeedLoadingPlaceholder
}
ListFooterComponent={ ListFooterComponent={
items?.length !== 0 && isMe ? CreateAnother : undefined !!data && items?.length !== 0 && isMe ? CreateAnother : undefined
} }
/> />
</View> </View>
@@ -181,7 +187,6 @@ function CreateAnother() {
function Empty() { function Empty() {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const confirmDialogControl = useDialogControl() const confirmDialogControl = useDialogControl()
const followersDialogControl = useDialogControl() const followersDialogControl = useDialogControl()
@@ -190,7 +195,7 @@ function Empty() {
const {needsEmailVerification} = useEmail() const {needsEmailVerification} = useEmail()
const verifyEmailControl = useDialogControl() const verifyEmailControl = useDialogControl()
const [isGenerating, setIsGenerating] = React.useState(false) const [isGenerating, setIsGenerating] = useState(false)
const {mutate: generateStarterPack} = useGenerateStarterPackMutation({ const {mutate: generateStarterPack} = useGenerateStarterPackMutation({
onSuccess: ({uri}) => { onSuccess: ({uri}) => {
@@ -227,16 +232,10 @@ function Empty() {
a.justify_between, a.justify_between,
a.gap_lg, a.gap_lg,
a.shadow_lg, a.shadow_lg,
{marginTop: 1}, {marginTop: a.border.borderWidth},
]}> ]}>
<View style={[a.gap_xs]}> <View style={[a.gap_xs]}>
<Text <Text style={[a.font_bold, a.text_lg, {color: 'white'}]}>
style={[
a.font_bold,
a.text_lg,
t.atoms.text_contrast_medium,
{color: 'white'},
]}>
<Trans>You haven't created a starter pack yet!</Trans> <Trans>You haven't created a starter pack yet!</Trans>
</Text> </Text>
<Text style={[a.text_md, {color: 'white'}]}> <Text style={[a.text_md, {color: 'white'}]}>
+7 -1
View File
@@ -13,6 +13,7 @@ import {useTheme} from '#/alf'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {LinearGradientBackground} from '#/components/LinearGradientBackground'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
const LazyViewShot = React.lazy( const LazyViewShot = React.lazy(
// @ts-expect-error dynamic import // @ts-expect-error dynamic import
@@ -30,7 +31,12 @@ export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
) { ) {
const {record} = starterPack const {record} = starterPack
if (!AppBskyGraphStarterpack.isRecord(record)) { if (
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
record,
AppBskyGraphStarterpack.isRecord,
)
) {
return null return null
} }
+7 -1
View File
@@ -18,6 +18,7 @@ import * as Dialog from '#/components/Dialog'
import {DialogControlProps} from '#/components/Dialog' import {DialogControlProps} from '#/components/Dialog'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {QrCode} from '#/components/StarterPack/QrCode' import {QrCode} from '#/components/StarterPack/QrCode'
import * as bsky from '#/types/bsky'
export function QrCodeDialog({ export function QrCodeDialog({
starterPack, starterPack,
@@ -77,7 +78,12 @@ export function QrCodeDialog({
} else { } else {
setIsProcessing(true) setIsProcessing(true)
if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) { if (
!bsky.validate(
starterPack.record,
AppBskyGraphStarterpack.validateRecord,
)
) {
return return
} }
+13 -7
View File
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api' import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
@@ -15,11 +15,12 @@ import {atoms as a, useTheme} from '#/alf'
import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack' import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack'
import {Link as BaseLink, LinkProps as BaseLinkProps} from '#/components/Link' import {Link as BaseLink, LinkProps as BaseLinkProps} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
export function Default({ export function Default({
starterPack, starterPack,
}: { }: {
starterPack?: AppBskyGraphDefs.StarterPackViewBasic starterPack?: bsky.starterPack.AnyStarterPackView
}) { }) {
if (!starterPack) return null if (!starterPack) return null
return ( return (
@@ -32,7 +33,7 @@ export function Default({
export function Notification({ export function Notification({
starterPack, starterPack,
}: { }: {
starterPack?: AppBskyGraphDefs.StarterPackViewBasic starterPack?: bsky.starterPack.AnyStarterPackView
}) { }) {
if (!starterPack) return null if (!starterPack) return null
return ( return (
@@ -47,7 +48,7 @@ export function Card({
noIcon, noIcon,
noDescription, noDescription,
}: { }: {
starterPack: AppBskyGraphDefs.StarterPackViewBasic starterPack: bsky.starterPack.AnyStarterPackView
noIcon?: boolean noIcon?: boolean
noDescription?: boolean noDescription?: boolean
}) { }) {
@@ -57,7 +58,12 @@ export function Card({
const t = useTheme() const t = useTheme()
const {currentAccount} = useSession() const {currentAccount} = useSession()
if (!AppBskyGraphStarterpack.isRecord(record)) { if (
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
record,
AppBskyGraphStarterpack.isRecord,
)
) {
return null return null
} }
@@ -100,7 +106,7 @@ export function Link({
starterPack, starterPack,
children, children,
}: { }: {
starterPack: AppBskyGraphDefs.StarterPackViewBasic starterPack: bsky.starterPack.AnyStarterPackView
onPress?: () => void onPress?: () => void
children: BaseLinkProps['children'] children: BaseLinkProps['children']
}) { }) {
@@ -139,7 +145,7 @@ export function Link({
export function Embed({ export function Embed({
starterPack, starterPack,
}: { }: {
starterPack: AppBskyGraphDefs.StarterPackViewBasic starterPack: bsky.starterPack.AnyStarterPackView
}) { }) {
const t = useTheme() const t = useTheme()
const imageUri = getStarterPackOgCard(starterPack) const imageUri = getStarterPackOgCard(starterPack)
@@ -38,7 +38,7 @@ export function WizardEditListDialog({
state: WizardState state: WizardState
dispatch: (action: WizardAction) => void dispatch: (action: WizardAction) => void
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
profile: AppBskyActorDefs.ProfileViewBasic profile: AppBskyActorDefs.ProfileViewDetailed
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
@@ -22,6 +22,7 @@ import {Button, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
import {Checkbox} from '#/components/forms/Toggle' import {Checkbox} from '#/components/forms/Toggle'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
function WizardListCard({ function WizardListCard({
type, type,
@@ -123,7 +124,7 @@ export function WizardProfileCard({
btnType: 'checkbox' | 'remove' btnType: 'checkbox' | 'remove'
state: WizardState state: WizardState
dispatch: (action: WizardAction) => void dispatch: (action: WizardAction) => void
profile: AppBskyActorDefs.ProfileViewBasic profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
}) { }) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
+7 -1
View File
@@ -27,6 +27,7 @@ import {Link} from '#/components/Link'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import * as Hider from '#/components/moderation/Hider' import * as Hider from '#/components/moderation/Hider'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
function getBlackColor(t: ReturnType<typeof useTheme>) { function getBlackColor(t: ReturnType<typeof useTheme>) {
return select(t.name, { return select(t.name, {
@@ -78,7 +79,12 @@ export function VideoPostCard({
if (!AppBskyEmbedVideo.isView(embed)) return null if (!AppBskyEmbedVideo.isView(embed)) return null
const author = post.author const author = post.author
const text = AppBskyFeedPost.isRecord(post.record) ? post.record?.text : '' const text = bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
? post.record?.text
: ''
const likeCount = post?.likeCount ?? 0 const likeCount = post?.likeCount ?? 0
const repostCount = post?.repostCount ?? 0 const repostCount = post?.repostCount ?? 0
const {thumbnail} = embed const {thumbnail} = embed
+5 -1
View File
@@ -29,6 +29,7 @@ import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil' import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
interface WhoCanReplyProps { interface WhoCanReplyProps {
@@ -48,7 +49,10 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
* unexpectedly, we should check to make sure it's for sure the root URI. * unexpectedly, we should check to make sure it's for sure the root URI.
*/ */
const rootUri = const rootUri =
AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
) && post.record.reply?.root
? post.record.reply.root.uri ? post.record.reply.root.uri
: post.uri : post.uri
const settings = React.useMemo(() => { const settings = React.useMemo(() => {
+2 -1
View File
@@ -4,6 +4,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {getDateAgo} from '#/lib/strings/time'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isIOS, isWeb} from '#/platform/detection' import {isIOS, isWeb} from '#/platform/detection'
import { import {
@@ -101,7 +102,7 @@ function BirthdayInner({
onChangeDate={newDate => setDate(new Date(newDate))} onChangeDate={newDate => setDate(new Date(newDate))}
label={_(msg`Birthday`)} label={_(msg`Birthday`)}
accessibilityHint={_(msg`Enter your birth date`)} accessibilityHint={_(msg`Enter your birth date`)}
maximumDate={new Date()} maximumDate={getDateAgo(13)}
/> />
</View> </View>
+125 -59
View File
@@ -1,5 +1,5 @@
import React, {memo, useRef, useState} from 'react' import {memo, useEffect, useMemo, useState} from 'react'
import {TextInput, View} from 'react-native' import {View} from 'react-native'
import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api' import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -8,12 +8,19 @@ import {EMBED_SCRIPT} from '#/lib/constants'
import {niceDate} from '#/lib/strings/time' import {niceDate} from '#/lib/strings/time'
import {toShareUrl} from '#/lib/strings/url-helpers' import {toShareUrl} from '#/lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import * as ToggleButton from '#/components/forms/ToggleButton'
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon,
ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon,
} from '#/components/icons/Chevron'
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {Button, ButtonIcon, ButtonText} from '../Button'
export type ColorModeValues = 'system' | 'light' | 'dark'
type EmbedDialogProps = { type EmbedDialogProps = {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
@@ -44,11 +51,12 @@ function EmbedDialogInner({
}: Omit<EmbedDialogProps, 'control'>) { }: Omit<EmbedDialogProps, 'control'>) {
const t = useTheme() const t = useTheme()
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const ref = useRef<TextInput>(null)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [showCustomisation, setShowCustomisation] = useState(false)
const [colorMode, setColorMode] = useState<ColorModeValues>('system')
// reset copied state after 2 seconds // reset copied state after 2 seconds
React.useEffect(() => { useEffect(() => {
if (copied) { if (copied) {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
setCopied(false) setCopied(false)
@@ -57,7 +65,7 @@ function EmbedDialogInner({
} }
}, [copied]) }, [copied])
const snippet = React.useMemo(() => { const snippet = useMemo(() => {
function toEmbedUrl(href: string) { function toEmbedUrl(href: string) {
return toShareUrl(href) + '?ref_src=embed' return toShareUrl(href) + '?ref_src=embed'
} }
@@ -75,9 +83,11 @@ function EmbedDialogInner({
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x // x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml( return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
postUri, postUri,
)}" data-bluesky-cid="${escapeHtml(postCid)}"><p lang="${escapeHtml( )}" data-bluesky-cid="${escapeHtml(
lang, postCid,
)}">${escapeHtml(record.text)}${ )}" data-bluesky-embed-color-mode="${escapeHtml(
colorMode,
)}"><p lang="${escapeHtml(lang)}">${escapeHtml(record.text)}${
record.embed record.embed
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>` ? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
: '' : ''
@@ -88,60 +98,116 @@ function EmbedDialogInner({
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml( )}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
niceDate(i18n, timestamp), niceDate(i18n, timestamp),
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>` )}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
}, [i18n, postUri, postCid, record, timestamp, postAuthor]) }, [i18n, postUri, postCid, record, timestamp, postAuthor, colorMode])
return ( return (
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}> <Dialog.Inner label={_(msg`Embed post`)} style={[{maxWidth: 500}]}>
<View style={[a.gap_sm, a.pb_lg]}> <View style={[a.gap_lg]}>
<Text style={[a.text_2xl, a.font_bold]}> <View style={[a.gap_sm]}>
<Trans>Embed post</Trans> <Text style={[a.text_2xl, a.font_heavy]}>
</Text> <Trans>Embed post</Trans>
<Text </Text>
style={[a.text_md, t.atoms.text_contrast_medium, a.leading_normal]}> <Text
<Trans> style={[a.text_md, t.atoms.text_contrast_medium, a.leading_normal]}>
Embed this post in your website. Simply copy the following snippet <Trans>
and paste it into the HTML code of your website. Embed this post in your website. Simply copy the following snippet
</Trans> and paste it into the HTML code of your website.
</Text> </Trans>
</View> </Text>
<View style={[a.flex_row, a.gap_sm]}>
<View style={[a.flex_1]}>
<TextField.Root>
<TextField.Icon icon={CodeBrackets} />
<TextField.Input
label={_(msg`Embed HTML code`)}
editable={false}
selection={{start: 0, end: snippet.length}}
value={snippet}
style={{}}
/>
</TextField.Root>
</View> </View>
<Button <View
label={_(msg`Copy code`)} style={[
color="primary" a.border,
variant="solid" t.atoms.border_contrast_low,
size="large" a.rounded_sm,
onPress={() => { a.overflow_hidden,
ref.current?.focus() ]}>
ref.current?.setSelection(0, snippet.length) <Button
navigator.clipboard.writeText(snippet) label={
setCopied(true) showCustomisation
}}> ? _(msg`Hide customization options`)
{copied ? ( : _(msg`Show customization options`)
<> }
<ButtonIcon icon={Check} /> color="secondary"
<ButtonText> variant="ghost"
<Trans>Copied!</Trans> size="small"
</ButtonText> shape="default"
</> onPress={() => setShowCustomisation(c => !c)}
) : ( style={[
a.justify_start,
showCustomisation && t.atoms.bg_contrast_25,
]}>
<ButtonIcon
icon={showCustomisation ? ChevronBottomIcon : ChevronRightIcon}
/>
<ButtonText> <ButtonText>
<Trans>Copy code</Trans> <Trans>Customization options</Trans>
</ButtonText> </ButtonText>
</Button>
{showCustomisation && (
<View style={[a.gap_sm, a.p_md]}>
<Text style={[t.atoms.text_contrast_medium, a.font_bold]}>
<Trans>Color theme</Trans>
</Text>
<ToggleButton.Group
label={_(msg`Color mode`)}
values={[colorMode]}
onChange={([value]) => setColorMode(value as ColorModeValues)}>
<ToggleButton.Button name="system" label={_(msg`System`)}>
<ToggleButton.ButtonText>
<Trans>System</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button name="light" label={_(msg`Light`)}>
<ToggleButton.ButtonText>
<Trans>Light</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button name="dark" label={_(msg`Dark`)}>
<ToggleButton.ButtonText>
<Trans>Dark</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
</ToggleButton.Group>
</View>
)} )}
</Button> </View>
<View style={[a.flex_row, a.gap_sm]}>
<View style={[a.flex_1]}>
<TextField.Root>
<TextField.Icon icon={CodeBracketsIcon} />
<TextField.Input
label={_(msg`Embed HTML code`)}
editable={false}
selection={{start: 0, end: snippet.length}}
value={snippet}
/>
</TextField.Root>
</View>
<Button
label={_(msg`Copy code`)}
color="primary"
variant="solid"
size="large"
onPress={() => {
navigator.clipboard.writeText(snippet)
setCopied(true)
}}>
{copied ? (
<>
<ButtonIcon icon={CheckIcon} />
<ButtonText>
<Trans>Copied!</Trans>
</ButtonText>
</>
) : (
<ButtonText>
<Trans>Copy code</Trans>
</ButtonText>
)}
</Button>
</View>
</View> </View>
<Dialog.Close /> <Dialog.Close />
</Dialog.Inner> </Dialog.Inner>
+1 -1
View File
@@ -318,7 +318,7 @@ function MutedWordsInner() {
<View style={[a.pt_xs]}> <View style={[a.pt_xs]}>
<Button <Button
disabled={isPending || !field} disabled={isPending || !field}
label={_(msg`Add mute word for configured settings`)} label={_(msg`Add mute word with chosen settings`)}
size="large" size="large"
color="primary" color="primary"
variant="solid" variant="solid"
@@ -209,7 +209,7 @@ export function PostInteractionSettingsDialogControlledInner(
props.control.close() props.control.close()
} catch (e: any) { } catch (e: any) {
logger.error(`Failed to save post interaction settings`, { logger.error(`Failed to save post interaction settings`, {
context: 'PostInteractionSettingsDialogControlledInner', source: 'PostInteractionSettingsDialogControlledInner',
safeMessage: e.message, safeMessage: e.message,
}) })
Toast.show( Toast.show(
+1 -1
View File
@@ -43,7 +43,7 @@ function SigninDialogInner({}: {control: Dialog.DialogOuterProps['control']}) {
return ( return (
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={_(msg`Sign into Bluesky or create a new account`)} label={_(msg`Sign in to Bluesky or create a new account`)}
style={[gtMobile ? {width: 'auto', maxWidth: 420} : a.w_full]}> style={[gtMobile ? {width: 'auto', maxWidth: 420} : a.w_full]}>
<View style={[!isNative && a.p_2xl]}> <View style={[!isNative && a.p_2xl]}>
<View <View
+6 -9
View File
@@ -1,10 +1,6 @@
import React, {useCallback} from 'react' import React, {useCallback} from 'react'
import {Keyboard, Pressable, View} from 'react-native' import {Keyboard, Pressable, View} from 'react-native'
import { import {ChatBskyConvoDefs, ModerationCause} from '@atproto/api'
AppBskyActorDefs,
ChatBskyConvoDefs,
ModerationCause,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
@@ -34,6 +30,7 @@ import {
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import * as bsky from '#/types/bsky'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble' import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble'
import {ReportDialog} from './ReportDialog' import {ReportDialog} from './ReportDialog'
@@ -49,7 +46,7 @@ let ConvoMenu = ({
style, style,
}: { }: {
convo: ChatBskyConvoDefs.ConvoView convo: ChatBskyConvoDefs.ConvoView
profile: Shadow<AppBskyActorDefs.ProfileViewBasic> profile: Shadow<bsky.profile.AnyProfileView>
control?: Menu.MenuControlProps control?: Menu.MenuControlProps
currentScreen: 'list' | 'conversation' currentScreen: 'list' | 'conversation'
showMarkAsRead?: boolean showMarkAsRead?: boolean
@@ -148,7 +145,7 @@ function MenuContent({
blockedByListControl, blockedByListControl,
}: { }: {
convo: ChatBskyConvoDefs.ConvoView convo: ChatBskyConvoDefs.ConvoView
profile: Shadow<AppBskyActorDefs.ProfileViewBasic> profile: Shadow<bsky.profile.AnyProfileView>
showMarkAsRead?: boolean showMarkAsRead?: boolean
blockInfo: { blockInfo: {
listBlocks: ModerationCause[] listBlocks: ModerationCause[]
@@ -176,9 +173,9 @@ function MenuContent({
const {mutate: muteConvo} = useMuteConvo(convoId, { const {mutate: muteConvo} = useMuteConvo(convoId, {
onSuccess: data => { onSuccess: data => {
if (data.convo.muted) { if (data.convo.muted) {
Toast.show(_(msg`Chat muted`)) Toast.show(_(msg({message: 'Chat muted', context: 'toast'})))
} else { } else {
Toast.show(_(msg`Chat unmuted`)) Toast.show(_(msg({message: 'Chat unmuted', context: 'toast'})))
} }
}, },
onError: () => { onError: () => {
+5 -1
View File
@@ -13,10 +13,12 @@ export function LeaveConvoPrompt({
control, control,
convoId, convoId,
currentScreen, currentScreen,
hasMessages = true,
}: { }: {
control: DialogOuterProps['control'] control: DialogOuterProps['control']
convoId: string convoId: string
currentScreen: 'list' | 'conversation' currentScreen: 'list' | 'conversation'
hasMessages?: boolean
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
@@ -39,7 +41,9 @@ export function LeaveConvoPrompt({
control={control} control={control}
title={_(msg`Leave conversation`)} title={_(msg`Leave conversation`)}
description={_( description={_(
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`, hasMessages
? msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`
: msg`Are you sure you want to leave this conversation?`,
)} )}
confirmButtonCta={_(msg`Leave`)} confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative" confirmButtonColor="negative"
+3 -1
View File
@@ -69,7 +69,9 @@ export let MessageMenu = ({
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo convo
.deleteMessage(message.id) .deleteMessage(message.id)
.then(() => Toast.show(_(msg`Message deleted`))) .then(() =>
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
)
.catch(() => Toast.show(_(msg`Failed to delete message`))) .catch(() => Toast.show(_(msg`Failed to delete message`)))
}, [_, convo, message.id]) }, [_, convo, message.id])
+34 -13
View File
@@ -8,18 +8,20 @@ import {useNavigation} from '@react-navigation/native'
import {useEmail} from '#/lib/hooks/useEmail' import {useEmail} from '#/lib/hooks/useEmail'
import {NavigationProp} from '#/lib/routes/types' import {NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members' import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {canBeMessaged} from '#/components/dms/util' import {canBeMessaged} from '#/components/dms/util'
import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message'
import {useDialogControl} from '../Dialog'
import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog'
export function MessageProfileButton({ export function MessageProfileButton({
profile, profile,
}: { }: {
profile: AppBskyActorDefs.ProfileView profile: AppBskyActorDefs.ProfileViewDetailed
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
@@ -27,10 +29,19 @@ export function MessageProfileButton({
const {needsEmailVerification} = useEmail() const {needsEmailVerification} = useEmail()
const verifyEmailControl = useDialogControl() const verifyEmailControl = useDialogControl()
const {data: convo, isPending} = useMaybeConvoForUser(profile.did) const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did)
const {mutate: initiateConvo} = useGetConvoForMembers({
onSuccess: ({convo}) => {
logEvent('chat:open', {logContext: 'ProfileHeader'})
navigation.navigate('MessagesConversation', {conversation: convo.id})
},
onError: () => {
Toast.show(_(msg`Failed to create conversation`))
},
})
const onPress = React.useCallback(() => { const onPress = React.useCallback(() => {
if (!convo?.id) { if (!convoAvailability?.canChat) {
return return
} }
@@ -39,15 +50,25 @@ export function MessageProfileButton({
return return
} }
if (convo && !convo.lastMessage) { if (convoAvailability.convo) {
logEvent('chat:open', {logContext: 'ProfileHeader'})
navigation.navigate('MessagesConversation', {
conversation: convoAvailability.convo.id,
})
} else {
logEvent('chat:create', {logContext: 'ProfileHeader'}) logEvent('chat:create', {logContext: 'ProfileHeader'})
initiateConvo([profile.did])
} }
logEvent('chat:open', {logContext: 'ProfileHeader'}) }, [
needsEmailVerification,
verifyEmailControl,
navigation,
profile.did,
initiateConvo,
convoAvailability,
])
navigation.navigate('MessagesConversation', {conversation: convo.id}) if (!convoAvailability) {
}, [needsEmailVerification, verifyEmailControl, convo, navigation])
if (isPending) {
// show pending state based on declaration // show pending state based on declaration
if (canBeMessaged(profile)) { if (canBeMessaged(profile)) {
return ( return (
@@ -69,7 +90,7 @@ export function MessageProfileButton({
} }
} }
if (convo) { if (convoAvailability.canChat) {
return ( return (
<> <>
<Button <Button
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' import {ModerationDecision} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -14,6 +14,7 @@ import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt' import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
export function MessagesListBlockedFooter({ export function MessagesListBlockedFooter({
recipient: initialRecipient, recipient: initialRecipient,
@@ -21,7 +22,7 @@ export function MessagesListBlockedFooter({
hasMessages, hasMessages,
moderation, moderation,
}: { }: {
recipient: AppBskyActorDefs.ProfileViewBasic recipient: bsky.profile.AnyProfileView
convoId: string convoId: string
hasMessages: boolean hasMessages: boolean
moderation: ModerationDecision moderation: ModerationDecision
+9 -6
View File
@@ -17,6 +17,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {Shadow} from '#/state/cache/profile-shadow' import {Shadow} from '#/state/cache/profile-shadow'
import {isConvoActive, useConvo} from '#/state/messages/convo' import {isConvoActive, useConvo} from '#/state/messages/convo'
import {ConvoItem} from '#/state/messages/convo/types'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu' import {ConvoMenu} from '#/components/dms/ConvoMenu'
@@ -31,7 +32,7 @@ export let MessagesListHeader = ({
profile, profile,
moderation, moderation,
}: { }: {
profile?: Shadow<AppBskyActorDefs.ProfileViewBasic> profile?: Shadow<AppBskyActorDefs.ProfileViewDetailed>
moderation?: ModerationDecision moderation?: ModerationDecision
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
@@ -52,10 +53,10 @@ export let MessagesListHeader = ({
}, [moderation]) }, [moderation])
const onPressBack = useCallback(() => { const onPressBack = useCallback(() => {
if (isWeb) { if (navigation.canGoBack()) {
navigation.replace('Messages', {})
} else {
navigation.goBack() navigation.goBack()
} else {
navigation.navigate('Messages', {})
} }
}, [navigation]) }, [navigation])
@@ -138,7 +139,7 @@ function HeaderReady({
moderation, moderation,
blockInfo, blockInfo,
}: { }: {
profile: Shadow<AppBskyActorDefs.ProfileViewBasic> profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
moderation: ModerationDecision moderation: ModerationDecision
blockInfo: { blockInfo: {
listBlocks: ModerationCause[] listBlocks: ModerationCause[]
@@ -157,8 +158,10 @@ function HeaderReady({
moderation.ui('displayName'), moderation.ui('displayName'),
) )
// @ts-ignore findLast is polyfilled - esb
const latestMessageFromOther = convoState.items.findLast( const latestMessageFromOther = convoState.items.findLast(
item => item.type === 'message' && item.message.sender.did === profile.did, (item: ConvoItem) =>
item.type === 'message' && item.message.sender.did === profile.did,
) )
const latestReportableMessage = const latestReportableMessage =
+25 -16
View File
@@ -1,6 +1,7 @@
import React, {memo, useMemo, useState} from 'react' import React, {memo, useMemo, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import { import {
$Typed,
AppBskyActorDefs, AppBskyActorDefs,
ChatBskyConvoDefs, ChatBskyConvoDefs,
ComAtprotoModerationCreateReport, ComAtprotoModerationCreateReport,
@@ -154,15 +155,16 @@ function SubmitStep({
mutationFn: async () => { mutationFn: async () => {
if (params.type === 'convoMessage') { if (params.type === 'convoMessage') {
const {convoId, message} = params const {convoId, message} = params
const subject: $Typed<ChatBskyConvoDefs.MessageRef> = {
$type: 'chat.bsky.convo.defs#messageRef',
messageId: message.id,
convoId,
did: message.sender.did,
}
const report = { const report = {
reasonType: reportOption.reason, reasonType: reportOption.reason,
subject: { subject,
$type: 'chat.bsky.convo.defs#messageRef',
messageId: message.id,
convoId,
did: message.sender.did,
} satisfies ChatBskyConvoDefs.MessageRef,
reason: details, reason: details,
} satisfies ComAtprotoModerationCreateReport.InputSchema } satisfies ComAtprotoModerationCreateReport.InputSchema
@@ -285,7 +287,7 @@ function DoneStep({
}: { }: {
convoId: string convoId: string
currentScreen: 'list' | 'conversation' currentScreen: 'list' | 'conversation'
profile: AppBskyActorDefs.ProfileViewBasic profile: AppBskyActorDefs.ProfileViewDetailed
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
@@ -309,6 +311,19 @@ function DoneStep({
}, },
}) })
let btnText = _(msg`Done`)
let toastMsg: string | undefined
if (actions.includes('leave') && actions.includes('block')) {
btnText = _(msg`Block and Delete`)
toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'}))
} else if (actions.includes('leave')) {
btnText = _(msg`Delete Conversation`)
toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'}))
} else if (actions.includes('block')) {
btnText = _(msg`Block User`)
toastMsg = _(msg({message: 'User blocked', context: 'toast'}))
}
const onPressPrimaryAction = () => { const onPressPrimaryAction = () => {
control.close(() => { control.close(() => {
if (actions.includes('block')) { if (actions.includes('block')) {
@@ -317,18 +332,12 @@ function DoneStep({
if (actions.includes('leave')) { if (actions.includes('leave')) {
leaveConvo() leaveConvo()
} }
if (toastMsg) {
Toast.show(toastMsg, 'check')
}
}) })
} }
let btnText = _(msg`Done`)
if (actions.includes('leave') && actions.includes('block')) {
btnText = _(msg`Block and Delete`)
} else if (actions.includes('leave')) {
btnText = _(msg`Delete Conversation`)
} else if (actions.includes('block')) {
btnText = _(msg`Block User`)
}
return ( return (
<View style={a.gap_2xl}> <View style={a.gap_2xl}>
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}> <View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
@@ -6,7 +6,7 @@ import React, {
useState, useState,
} from 'react' } from 'react'
import {TextInput, View} from 'react-native' import {TextInput, View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' import {moderateProfile, ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -28,13 +28,14 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
type Item = type Item =
| { | {
type: 'profile' type: 'profile'
key: string key: string
enabled: boolean enabled: boolean
profile: AppBskyActorDefs.ProfileView profile: bsky.profile.AnyProfileView
} }
| { | {
type: 'empty' type: 'empty'
@@ -76,7 +77,10 @@ export function SearchablePeopleList({
isFetching, isFetching,
} = useActorAutocompleteQuery(searchText, true, 12) } = useActorAutocompleteQuery(searchText, true, 12)
const {data: follows} = useProfileFollowsQuery(currentAccount?.did) const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
const {data: convos} = useListConvosQuery({enabled: showRecentConvos}) const {data: convos} = useListConvosQuery({
enabled: showRecentConvos,
status: 'accepted',
})
const items = useMemo(() => { const items = useMemo(() => {
let _items: Item[] = [] let _items: Item[] = []
@@ -330,7 +334,7 @@ function ProfileCard({
onPress, onPress,
}: { }: {
enabled: boolean enabled: boolean
profile: AppBskyActorDefs.ProfileView profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
onPress: (did: string) => void onPress: (did: string) => void
}) { }) {
+2 -2
View File
@@ -1,6 +1,6 @@
import {AppBskyActorDefs} from '@atproto/api' import * as bsky from '#/types/bsky'
export function canBeMessaged(profile: AppBskyActorDefs.ProfileView) { export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
switch (profile.associated?.chat?.allowIncoming) { switch (profile.associated?.chat?.allowIncoming) {
case 'none': case 'none':
return false return false
@@ -1,4 +1,5 @@
import React from 'react' import {useCallback, useImperativeHandle, useState} from 'react'
import {Keyboard} from 'react-native'
import DatePicker from 'react-native-date-picker' import DatePicker from 'react-native-date-picker'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
@@ -12,6 +13,7 @@ export const LabelText = TextField.LabelText
export function DateField({ export function DateField({
value, value,
inputRef,
onChangeDate, onChangeDate,
label, label,
isInvalid, isInvalid,
@@ -20,9 +22,9 @@ export function DateField({
maximumDate, maximumDate,
}: DateFieldProps) { }: DateFieldProps) {
const t = useTheme() const t = useTheme()
const [open, setOpen] = React.useState(false) const [open, setOpen] = useState(false)
const onChangeInternal = React.useCallback( const onChangeInternal = useCallback(
(date: Date) => { (date: Date) => {
setOpen(false) setOpen(false)
@@ -32,11 +34,25 @@ export function DateField({
[onChangeDate, setOpen], [onChangeDate, setOpen],
) )
const onPress = React.useCallback(() => { useImperativeHandle(
inputRef,
() => ({
focus: () => {
Keyboard.dismiss()
setOpen(true)
},
blur: () => {
setOpen(false)
},
}),
[],
)
const onPress = useCallback(() => {
setOpen(true) setOpen(true)
}, []) }, [])
const onCancel = React.useCallback(() => { const onCancel = useCallback(() => {
setOpen(false) setOpen(false)
}, []) }, [])
@@ -1,7 +1,7 @@
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {android, atoms as a, useTheme, web} from '#/alf' import {atoms as a, native, useTheme, web} from '#/alf'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
@@ -60,16 +60,16 @@ export function DateFieldButton({
onBlur={onBlur} onBlur={onBlur}
style={[ style={[
{ {
paddingTop: 12,
paddingBottom: 12,
paddingLeft: 14, paddingLeft: 14,
paddingRight: 14, paddingRight: 14,
borderColor: 'transparent', borderColor: 'transparent',
borderWidth: 2, borderWidth: 2,
}, },
android({ native({
minHeight: 57.5, paddingTop: 10,
paddingBottom: 10,
}), }),
web(a.py_md),
a.flex_row, a.flex_row,
a.flex_1, a.flex_1,
a.w_full, a.w_full,
+17 -2
View File
@@ -1,4 +1,4 @@
import React from 'react' import {useCallback, useImperativeHandle} from 'react'
import {Keyboard, View} from 'react-native' import {Keyboard, View} from 'react-native'
import DatePicker from 'react-native-date-picker' import DatePicker from 'react-native-date-picker'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
@@ -25,6 +25,7 @@ export const LabelText = TextField.LabelText
*/ */
export function DateField({ export function DateField({
value, value,
inputRef,
onChangeDate, onChangeDate,
testID, testID,
label, label,
@@ -36,7 +37,7 @@ export function DateField({
const t = useTheme() const t = useTheme()
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
const onChangeInternal = React.useCallback( const onChangeInternal = useCallback(
(date: Date | undefined) => { (date: Date | undefined) => {
if (date) { if (date) {
const formatted = toSimpleDateString(date) const formatted = toSimpleDateString(date)
@@ -46,6 +47,20 @@ export function DateField({
[onChangeDate], [onChangeDate],
) )
useImperativeHandle(
inputRef,
() => ({
focus: () => {
Keyboard.dismiss()
control.open()
},
blur: () => {
control.close()
},
}),
[control],
)
return ( return (
<> <>
<DateFieldButton <DateFieldButton
+2 -1
View File
@@ -34,6 +34,7 @@ const Input = TextField.createInput(InputBase as unknown as typeof TextInput)
export function DateField({ export function DateField({
value, value,
inputRef,
onChangeDate, onChangeDate,
label, label,
isInvalid, isInvalid,
@@ -58,9 +59,9 @@ export function DateField({
<TextField.Icon icon={CalendarDays} /> <TextField.Icon icon={CalendarDays} />
<Input <Input
value={toSimpleDateString(value)} value={toSimpleDateString(value)}
inputRef={inputRef as React.Ref<TextInput>}
label={label} label={label}
onChange={handleOnChange} onChange={handleOnChange}
onChangeText={() => {}}
testID={testID} testID={testID}
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
// @ts-expect-error not typed as <input type="date"> even though it is one // @ts-expect-error not typed as <input type="date"> even though it is one
+5
View File
@@ -1,7 +1,12 @@
export type DateFieldRef = {
focus: () => void
blur: () => void
}
export type DateFieldProps = { export type DateFieldProps = {
value: string | Date value: string | Date
onChangeDate: (date: string) => void onChangeDate: (date: string) => void
label: string label: string
inputRef?: React.Ref<DateFieldRef>
isInvalid?: boolean isInvalid?: boolean
testID?: string testID?: string
accessibilityHint?: string accessibilityHint?: string
+17 -15
View File
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {Keyboard, View} from 'react-native' import {Keyboard, View} from 'react-native'
import {msg} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {toNiceDomain} from '#/lib/strings/url-helpers' import {toNiceDomain} from '#/lib/strings/url-helpers'
@@ -43,21 +43,23 @@ export function HostingProvider({
{minimal ? ( {minimal ? (
<View style={[a.flex_row, a.align_center, a.flex_wrap]}> <View style={[a.flex_row, a.align_center, a.flex_wrap]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}> <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
You are creating an account on{' '} <Trans>
You are creating an account on{' '}
<Button
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Changes hosting provider`)}
onPress={onPressSelectService}
variant="ghost"
color="secondary"
size="tiny"
style={[a.px_xs, {marginHorizontal: tokens.space.xs * -1}]}>
<ButtonText style={[a.text_sm]}>
{toNiceDomain(serviceUrl)}
</ButtonText>
<ButtonIcon icon={PencilIcon} />
</Button>
</Trans>
</Text> </Text>
<Button
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Changes hosting provider`)}
onPress={onPressSelectService}
variant="ghost"
color="secondary"
size="tiny"
style={[a.px_xs, {marginLeft: tokens.space.xs * -1}]}>
<ButtonText style={[a.text_sm]}>
{toNiceDomain(serviceUrl)}
</ButtonText>
<ButtonIcon icon={PencilIcon} />
</Button>
</View> </View>
) : ( ) : (
<Button <Button
+6 -17
View File
@@ -15,6 +15,7 @@ import {
android, android,
applyFonts, applyFonts,
atoms as a, atoms as a,
ios,
TextStyleProp, TextStyleProp,
useAlf, useAlf,
useTheme, useTheme,
@@ -189,13 +190,13 @@ export function createInput(Component: typeof TextInput) {
a.px_xs, a.px_xs,
{ {
// paddingVertical doesn't work w/multiline - esb // paddingVertical doesn't work w/multiline - esb
paddingTop: 12,
paddingBottom: 13,
lineHeight: a.text_md.fontSize * 1.1875, lineHeight: a.text_md.fontSize * 1.1875,
textAlignVertical: rest.multiline ? 'top' : undefined, textAlignVertical: rest.multiline ? 'top' : undefined,
minHeight: rest.multiline ? 80 : undefined, minHeight: rest.multiline ? 80 : undefined,
minWidth: 0, minWidth: 0,
}, },
ios({paddingTop: 12, paddingBottom: 13}),
android(a.py_sm),
// fix for autofill styles covering border // fix for autofill styles covering border
web({ web({
paddingTop: 10, paddingTop: 10,
@@ -203,10 +204,6 @@ export function createInput(Component: typeof TextInput) {
marginTop: 2, marginTop: 2,
marginBottom: 2, marginBottom: 2,
}), }),
android({
paddingTop: 8,
paddingBottom: 8,
}),
style, style,
]) ])
@@ -355,17 +352,9 @@ export function SuffixText({
a.pr_sm, a.pr_sm,
a.text_md, a.text_md,
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
{ a.pointer_events_none,
pointerEvents: 'none', web([{marginTop: -2}, a.leading_snug]),
}, (ctx.hovered || ctx.focused) && {color: t.palette.contrast_800},
web({
marginTop: -2,
}),
ctx.hovered || ctx.focused
? {
color: t.palette.contrast_800,
}
: {},
style, style,
]}> ]}>
{children} {children}
+6
View File
@@ -11,14 +11,17 @@ import React from 'react'
import {formatDistance, Locale} from 'date-fns' import {formatDistance, Locale} from 'date-fns'
import { import {
ca, ca,
cy,
da, da,
de, de,
el, el,
enGB, enGB,
eo,
es, es,
eu, eu,
fi, fi,
fr, fr,
gd,
gl, gl,
hi, hi,
hu, hu,
@@ -53,15 +56,18 @@ const locales: Record<AppLanguage, Locale | undefined> = {
an: undefined, an: undefined,
ast: undefined, ast: undefined,
ca, ca,
cy,
da, da,
de, de,
el, el,
['en-GB']: enGB, ['en-GB']: enGB,
eo,
es, es,
eu, eu,
fi, fi,
fr, fr,
ga: undefined, ga: undefined,
gd,
gl, gl,
hi, hi,
hu, hu,
+2 -2
View File
@@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import {AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -9,12 +8,13 @@ import {Shadow} from '#/state/cache/types'
import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useRequireAuth} from '#/state/session' import {useRequireAuth} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import * as bsky from '#/types/bsky'
export function useFollowMethods({ export function useFollowMethods({
profile, profile,
logContext, logContext,
}: { }: {
profile: Shadow<AppBskyActorDefs.ProfileViewBasic> profile: Shadow<bsky.profile.AnyProfileView>
logContext: LogEvents['profile:follow']['logContext'] & logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext'] LogEvents['profile:unfollow']['logContext']
}) { }) {
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const CircleX_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.293-3.707a1 1 0 0 1 1.414 0L12 10.586l2.293-2.293a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 0 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 0 1 0-1.414Z',
})
@@ -268,6 +268,7 @@ export function LabelerLabelPreference({
a.rounded_sm, a.rounded_sm,
a.border, a.border,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
a.self_start,
]}> ]}>
<Text emoji style={[a.font_bold, t.atoms.text_contrast_low]}> <Text emoji style={[a.font_bold, t.atoms.text_contrast_low]}>
{currentPrefLabel} {currentPrefLabel}
+38 -13
View File
@@ -5,6 +5,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useLabelSubject} from '#/lib/moderation' import {useLabelSubject} from '#/lib/moderation'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo' import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
@@ -66,7 +67,7 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
/> />
) : ( ) : (
<> <>
<Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}> <Text style={[a.text_2xl, a.font_heavy, a.pb_xs, a.leading_tight]}>
{isAccount ? ( {isAccount ? (
<Trans>Labels on your account</Trans> <Trans>Labels on your account</Trans>
) : ( ) : (
@@ -122,6 +123,7 @@ function Label({
const sourceName = labeler const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@') ? sanitizeHandle(labeler.creator.handle, '@')
: label.src : label.src
const timeDiff = useGetTimeAgo({future: true})
return ( return (
<View <View
style={[ style={[
@@ -163,18 +165,41 @@ function Label({
<Trans>This label was applied by you.</Trans> <Trans>This label was applied by you.</Trans>
</Text> </Text>
) : ( ) : (
<View style={{flexDirection: 'row'}}> <View
<Text style={[t.atoms.text_contrast_medium]}> style={[
<Trans>Source: </Trans>{' '} a.flex_row,
a.justify_between,
a.gap_xl,
{paddingBottom: 1},
]}>
<Text
style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
<Trans>
Source:{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}>
{sourceName}
</InlineLinkText>
</Trans>
</Text> </Text>
<InlineLinkText {label.exp && (
label={sourceName} <View>
to={makeProfileLink( <Text
labeler ? labeler.creator : {did: label.src, handle: ''}, style={[
)} a.leading_snug,
onPress={() => control.close()}> a.text_sm,
{sourceName} a.italic,
</InlineLinkText> t.atoms.text_contrast_medium,
]}>
<Trans>Expires in {timeDiff(Date.now(), label.exp)}</Trans>
</Text>
</View>
)}
</View> </View>
)} )}
</View> </View>
@@ -230,7 +255,7 @@ function AppealForm({
}, },
onSuccess: () => { onSuccess: () => {
control.close() control.close()
Toast.show(_(msg`Appeal submitted`)) Toast.show(_(msg({message: 'Appeal submitted', context: 'toast'})))
}, },
}) })
@@ -3,14 +3,14 @@ import {ModerationCause} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {listUriToHref} from '#/lib/strings/url-helpers' import {listUriToHref} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useGutters, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {AppModerationCause} from '#/components/Pills' import {AppModerationCause} from '#/components/Pills'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -38,9 +38,11 @@ function ModerationDetailsDialogInner({
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
}) { }) {
const t = useTheme() const t = useTheme()
const xGutters = useGutters([0, 'base'])
const {_} = useLingui() const {_} = useLingui()
const desc = useModerationCauseDescription(modcause) const desc = useModerationCauseDescription(modcause)
const {currentAccount} = useSession() const {currentAccount} = useSession()
const timeDiff = useGetTimeAgo({future: true})
let name let name
let description let description
@@ -128,37 +130,88 @@ function ModerationDetailsDialogInner({
description = '' description = ''
} }
const sourceName =
desc.source || desc.sourceDisplayName || _(msg`an unknown labeler`)
return ( return (
<Dialog.ScrollableInner label={_(msg`Moderation details`)}> <Dialog.ScrollableInner
<Text emoji style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}> label={_(msg`Moderation details`)}
{name} contentContainerStyle={{
</Text> paddingLeft: 0,
<Text style={[t.atoms.text, a.text_md, a.leading_snug]}> paddingRight: 0,
{description} paddingBottom: 0,
</Text> }}>
<View style={[xGutters, a.pb_lg]}>
<Text emoji style={[t.atoms.text, a.text_2xl, a.font_heavy, a.mb_sm]}>
{name}
</Text>
<Text style={[t.atoms.text, a.text_sm, a.leading_snug]}>
{description}
</Text>
</View>
{modcause?.type === 'label' && ( {modcause?.type === 'label' && (
<View style={[a.pt_lg]}> <View
<Divider /> style={[
xGutters,
a.py_md,
a.border_t,
!isNative && t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
{
borderBottomLeftRadius: a.rounded_md.borderRadius,
borderBottomRightRadius: a.rounded_md.borderRadius,
},
]}>
{modcause.source.type === 'user' ? ( {modcause.source.type === 'user' ? (
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}> <Text style={[t.atoms.text, a.text_md, a.leading_snug]}>
<Trans>This label was applied by the author.</Trans> <Trans>This label was applied by the author.</Trans>
</Text> </Text>
) : ( ) : (
<> <>
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}> <View
<Trans> style={[
This label was applied by{' '} a.flex_row,
<InlineLinkText a.justify_between,
label={desc.source || _(msg`an unknown labeler`)} a.gap_xl,
to={makeProfileLink({did: modcause.label.src, handle: ''})} {paddingBottom: 1},
onPress={() => control.close()} ]}>
style={a.text_md}> <Text
{desc.source || _(msg`an unknown labeler`)} style={[
</InlineLinkText> a.flex_1,
. a.leading_snug,
</Trans> t.atoms.text_contrast_medium,
</Text> ]}
numberOfLines={1}>
<Trans>
Source:{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink({
did: modcause.label.src,
handle: '',
})}
onPress={() => control.close()}>
{sourceName}
</InlineLinkText>
</Trans>
</Text>
{modcause.label.exp && (
<View>
<Text
style={[
a.leading_snug,
a.text_sm,
a.italic,
t.atoms.text_contrast_medium,
]}>
<Trans>
Expires in {timeDiff(Date.now(), modcause.label.exp)}
</Trans>
</Text>
</View>
)}
</View>
</> </>
)} )}
</View> </View>
+3 -3
View File
@@ -1,7 +1,7 @@
import {StyleProp, ViewStyle} from 'react-native' import {StyleProp, ViewStyle} from 'react-native'
import {ModerationCause, ModerationUI} from '@atproto/api' import {ModerationCause, ModerationUI} from '@atproto/api'
import {getModerationCauseKey} from '#/lib/moderation' import {getModerationCauseKey, unique} from '#/lib/moderation'
import * as Pills from '#/components/Pills' import * as Pills from '#/components/Pills'
export function PostAlerts({ export function PostAlerts({
@@ -22,7 +22,7 @@ export function PostAlerts({
return ( return (
<Pills.Row size={size} style={[size === 'sm' && {marginLeft: -3}, style]}> <Pills.Row size={size} style={[size === 'sm' && {marginLeft: -3}, style]}>
{modui.alerts.map(cause => ( {modui.alerts.filter(unique).map(cause => (
<Pills.Label <Pills.Label
key={getModerationCauseKey(cause)} key={getModerationCauseKey(cause)}
cause={cause} cause={cause}
@@ -30,7 +30,7 @@ export function PostAlerts({
noBg={size === 'sm'} noBg={size === 'sm'}
/> />
))} ))}
{modui.informs.map(cause => ( {modui.informs.filter(unique).map(cause => (
<Pills.Label <Pills.Label
key={getModerationCauseKey(cause)} key={getModerationCauseKey(cause)}
cause={cause} cause={cause}

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