Merge branch 'main' into patch-3
This commit is contained in:
commit
8d394a3541
69 changed files with 2060 additions and 125 deletions
4
.github/workflows/build-submit-android.yml
vendored
4
.github/workflows/build-submit-android.yml
vendored
|
@ -26,9 +26,9 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: 🔧 Setup Node
|
- name: 🔧 Setup Node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version-file: .nvmrc
|
||||||
cache: yarn
|
cache: yarn
|
||||||
|
|
||||||
- name: 🔨 Setup EAS
|
- name: 🔨 Setup EAS
|
||||||
|
|
4
.github/workflows/build-submit-ios.yml
vendored
4
.github/workflows/build-submit-ios.yml
vendored
|
@ -28,9 +28,9 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: 🔧 Setup Node
|
- name: 🔧 Setup Node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version-file: .nvmrc
|
||||||
cache: yarn
|
cache: yarn
|
||||||
|
|
||||||
- name: 🔨 Setup EAS
|
- name: 🔨 Setup EAS
|
||||||
|
|
8
.github/workflows/lint.yml
vendored
8
.github/workflows/lint.yml
vendored
|
@ -32,12 +32,12 @@ jobs:
|
||||||
name: Run tests
|
name: Run tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Install node 18
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 18
|
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
- name: Install node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
- name: Yarn install
|
- name: Yarn install
|
||||||
uses: Wandalen/wretry.action@master
|
uses: Wandalen/wretry.action@master
|
||||||
with:
|
with:
|
||||||
|
|
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
18
|
|
@ -23,7 +23,7 @@ COPY . .
|
||||||
RUN mkdir --parents $NVM_DIR && \
|
RUN mkdir --parents $NVM_DIR && \
|
||||||
wget \
|
wget \
|
||||||
--output-document=/tmp/nvm-install.sh \
|
--output-document=/tmp/nvm-install.sh \
|
||||||
https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh && \
|
https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh && \
|
||||||
bash /tmp/nvm-install.sh
|
bash /tmp/nvm-install.sh
|
||||||
|
|
||||||
RUN \. "$NVM_DIR/nvm.sh" && \
|
RUN \. "$NVM_DIR/nvm.sh" && \
|
||||||
|
|
|
@ -65,8 +65,6 @@ If you discover any security issues, please send an email to security@bsky.app.
|
||||||
|
|
||||||
Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party can be as seamless as first-party through custom feeds, federated services, clients, and more.
|
Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party can be as seamless as first-party through custom feeds, federated services, clients, and more.
|
||||||
|
|
||||||
If you're a developer interested in building on atproto, we'd love to email you a Bluesky invite code. Simply share your GitHub (or similar) profile with us via [this form](https://forms.gle/BF21oxVNZiDjDhXF9).
|
|
||||||
|
|
||||||
## License (MIT)
|
## License (MIT)
|
||||||
|
|
||||||
See [./LICENSE](./LICENSE) for the full license.
|
See [./LICENSE](./LICENSE) for the full license.
|
||||||
|
|
1
assets/icons/checkThick_stroke2_corner0_rounded.svg
Normal file
1
assets/icons/checkThick_stroke2_corner0_rounded.svg
Normal file
|
@ -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="M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 300 B |
1
assets/icons/clipboard_stroke2_corner2_rounded.svg
Normal file
1
assets/icons/clipboard_stroke2_corner2_rounded.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 422 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 269 B |
1
assets/icons/mute_stroke2_corner0_rounded.svg
Normal file
1
assets/icons/mute_stroke2_corner0_rounded.svg
Normal file
|
@ -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="M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 465 B |
1
assets/icons/pageText_stroke2_corner0_rounded.svg
Normal file
1
assets/icons/pageText_stroke2_corner0_rounded.svg
Normal file
|
@ -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="M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 356 B |
|
@ -6,9 +6,9 @@ To build the SPA bundle (`bundle.web.js`), first get a JavaScript development
|
||||||
environment set up. Either follow the top-level README, or something quick
|
environment set up. Either follow the top-level README, or something quick
|
||||||
like:
|
like:
|
||||||
|
|
||||||
# install nodejs 18 (specifically)
|
# install nodejs
|
||||||
nvm install 18
|
nvm install
|
||||||
nvm use 18
|
nvm use
|
||||||
npm install --global yarn
|
npm install --global yarn
|
||||||
|
|
||||||
# setup tools and deps (in top level of this repo)
|
# setup tools and deps (in top level of this repo)
|
||||||
|
|
|
@ -205,6 +205,11 @@
|
||||||
[data-tooltip]:hover::before {
|
[data-tooltip]:hover::before {
|
||||||
display:block;
|
display:block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* NativeDropdown component */
|
||||||
|
.nativeDropdown-item:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% include "scripts.html" %}
|
{% include "scripts.html" %}
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.69.0",
|
"version": "1.70.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
@ -43,7 +43,7 @@
|
||||||
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
|
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.9.5",
|
"@atproto/api": "^0.10.0",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
|
|
@ -497,7 +497,8 @@ const LINKING = {
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
return buildStateObject('Flat', name, params)
|
const res = buildStateObject('Flat', name, params)
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -181,6 +181,8 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||||
|
|
||||||
const logoAnimations =
|
const logoAnimations =
|
||||||
reduceMotion === true ? reducedLogoAnimation : logoAnimation
|
reduceMotion === true ? reducedLogoAnimation : logoAnimation
|
||||||
|
// special off-spec color for dark mode
|
||||||
|
const logoBg = isDarkMode ? '#0F1824' : '#fff'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{flex: 1}} onLayout={onLayout}>
|
<View style={{flex: 1}} onLayout={onLayout}>
|
||||||
|
@ -232,7 +234,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<AnimatedLogo
|
<AnimatedLogo
|
||||||
fill="#fff"
|
fill={logoBg}
|
||||||
style={[{opacity: 0}, logoAnimations]}
|
style={[{opacity: 0}, logoAnimations]}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
@ -253,7 +255,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||||
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
|
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<AnimatedLogo fill="#fff" style={[logoAnimations]} />
|
<AnimatedLogo fill={logoBg} style={[logoAnimations]} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
}>
|
}>
|
||||||
{!isAnimationComplete && (
|
{!isAnimationComplete && (
|
||||||
|
@ -261,10 +263,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||||
style={[
|
style={[
|
||||||
StyleSheet.absoluteFillObject,
|
StyleSheet.absoluteFillObject,
|
||||||
{
|
{
|
||||||
backgroundColor: isDarkMode
|
backgroundColor: logoBg,
|
||||||
? // special off-spec color for dark mode
|
|
||||||
'#0F1824'
|
|
||||||
: '#fff',
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import {web, native} from '#/alf/util/platform'
|
||||||
import * as tokens from '#/alf/tokens'
|
import * as tokens from '#/alf/tokens'
|
||||||
|
|
||||||
export const atoms = {
|
export const atoms = {
|
||||||
|
@ -113,6 +114,9 @@ export const atoms = {
|
||||||
flex_wrap: {
|
flex_wrap: {
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
},
|
},
|
||||||
|
flex_0: {
|
||||||
|
flex: web('0 0 auto') || (native(0) as number),
|
||||||
|
},
|
||||||
flex_1: {
|
flex_1: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|
|
@ -188,7 +188,7 @@ export function Close() {
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="primary"
|
color="secondary"
|
||||||
shape="round"
|
shape="round"
|
||||||
onPress={close}
|
onPress={close}
|
||||||
label={_(msg`Close active dialog`)}>
|
label={_(msg`Close active dialog`)}>
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
|
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
|
||||||
import {atoms as a, TextStyleProp, flatten} from '#/alf'
|
import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
|
||||||
import {InlineLink} from '#/components/Link'
|
import {InlineLink} from '#/components/Link'
|
||||||
import {Text, TextProps} from '#/components/Typography'
|
import {Text, TextProps} from '#/components/Typography'
|
||||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||||
import {getAgent} from '#/state/session'
|
import {getAgent} from '#/state/session'
|
||||||
|
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
|
|
||||||
const WORD_WRAP = {wordWrap: 1}
|
const WORD_WRAP = {wordWrap: 1}
|
||||||
|
|
||||||
|
@ -17,6 +22,8 @@ export function RichText({
|
||||||
disableLinks,
|
disableLinks,
|
||||||
resolveFacets = false,
|
resolveFacets = false,
|
||||||
selectable,
|
selectable,
|
||||||
|
enableTags = false,
|
||||||
|
authorHandle,
|
||||||
}: TextStyleProp &
|
}: TextStyleProp &
|
||||||
Pick<TextProps, 'selectable'> & {
|
Pick<TextProps, 'selectable'> & {
|
||||||
value: RichTextAPI | string
|
value: RichTextAPI | string
|
||||||
|
@ -24,6 +31,8 @@ export function RichText({
|
||||||
numberOfLines?: number
|
numberOfLines?: number
|
||||||
disableLinks?: boolean
|
disableLinks?: boolean
|
||||||
resolveFacets?: boolean
|
resolveFacets?: boolean
|
||||||
|
enableTags?: boolean
|
||||||
|
authorHandle?: string
|
||||||
}) {
|
}) {
|
||||||
const detected = React.useRef(false)
|
const detected = React.useRef(false)
|
||||||
const [richText, setRichText] = React.useState<RichTextAPI>(() =>
|
const [richText, setRichText] = React.useState<RichTextAPI>(() =>
|
||||||
|
@ -85,6 +94,7 @@ export function RichText({
|
||||||
for (const segment of richText.segments()) {
|
for (const segment of richText.segments()) {
|
||||||
const link = segment.link
|
const link = segment.link
|
||||||
const mention = segment.mention
|
const mention = segment.mention
|
||||||
|
const tag = segment.tag
|
||||||
if (
|
if (
|
||||||
mention &&
|
mention &&
|
||||||
AppBskyRichtextFacet.validateMention(mention).success &&
|
AppBskyRichtextFacet.validateMention(mention).success &&
|
||||||
|
@ -118,6 +128,21 @@ export function RichText({
|
||||||
</InlineLink>,
|
</InlineLink>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else if (
|
||||||
|
!disableLinks &&
|
||||||
|
enableTags &&
|
||||||
|
tag &&
|
||||||
|
AppBskyRichtextFacet.validateTag(tag).success
|
||||||
|
) {
|
||||||
|
els.push(
|
||||||
|
<RichTextTag
|
||||||
|
key={key}
|
||||||
|
text={segment.text}
|
||||||
|
style={styles}
|
||||||
|
selectable={selectable}
|
||||||
|
authorHandle={authorHandle}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
els.push(segment.text)
|
els.push(segment.text)
|
||||||
}
|
}
|
||||||
|
@ -136,3 +161,79 @@ export function RichText({
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RichTextTag({
|
||||||
|
text: tag,
|
||||||
|
style,
|
||||||
|
selectable,
|
||||||
|
authorHandle,
|
||||||
|
}: {
|
||||||
|
text: string
|
||||||
|
selectable?: boolean
|
||||||
|
authorHandle?: string
|
||||||
|
} & TextStyleProp) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const control = useTagMenuControl()
|
||||||
|
const {
|
||||||
|
state: hovered,
|
||||||
|
onIn: onHoverIn,
|
||||||
|
onOut: onHoverOut,
|
||||||
|
} = useInteractionState()
|
||||||
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||||
|
const {
|
||||||
|
state: pressed,
|
||||||
|
onIn: onPressIn,
|
||||||
|
onOut: onPressOut,
|
||||||
|
} = useInteractionState()
|
||||||
|
|
||||||
|
const open = React.useCallback(() => {
|
||||||
|
control.open()
|
||||||
|
}, [control])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* N.B. On web, this is wrapped in another pressable comopnent with a11y
|
||||||
|
* labels, etc. That's why only some of these props are applied here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<TagMenu control={control} tag={tag} authorHandle={authorHandle}>
|
||||||
|
<Text
|
||||||
|
selectable={selectable}
|
||||||
|
{...native({
|
||||||
|
accessibilityLabel: _(msg`Hashtag: ${tag}`),
|
||||||
|
accessibilityHint: _(msg`Click here to open tag menu for ${tag}`),
|
||||||
|
accessibilityRole: isNative ? 'button' : undefined,
|
||||||
|
onPress: open,
|
||||||
|
onPressIn: onPressIn,
|
||||||
|
onPressOut: onPressOut,
|
||||||
|
})}
|
||||||
|
{...web({
|
||||||
|
onMouseEnter: onHoverIn,
|
||||||
|
onMouseLeave: onHoverOut,
|
||||||
|
})}
|
||||||
|
// @ts-ignore
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
style={[
|
||||||
|
style,
|
||||||
|
{
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
color: t.palette.primary_500,
|
||||||
|
},
|
||||||
|
web({
|
||||||
|
cursor: 'pointer',
|
||||||
|
}),
|
||||||
|
(hovered || focused || pressed) && {
|
||||||
|
...web({outline: 0}),
|
||||||
|
textDecorationLine: 'underline',
|
||||||
|
textDecorationColor: t.palette.primary_500,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{tag}
|
||||||
|
</Text>
|
||||||
|
</TagMenu>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
279
src/components/TagMenu/index.tsx
Normal file
279
src/components/TagMenu/index.tsx
Normal file
|
@ -0,0 +1,279 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||||
|
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||||
|
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
|
import {makeSearchLink} from '#/lib/routes/links'
|
||||||
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {
|
||||||
|
usePreferencesQuery,
|
||||||
|
useUpsertMutedWordsMutation,
|
||||||
|
useRemoveMutedWordMutation,
|
||||||
|
} from '#/state/queries/preferences'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||||
|
|
||||||
|
export function useTagMenuControl() {
|
||||||
|
return Dialog.useDialogControl()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagMenu({
|
||||||
|
children,
|
||||||
|
control,
|
||||||
|
tag,
|
||||||
|
authorHandle,
|
||||||
|
}: React.PropsWithChildren<{
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
tag: string
|
||||||
|
authorHandle?: string
|
||||||
|
}>) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const {isLoading: isPreferencesLoading, data: preferences} =
|
||||||
|
usePreferencesQuery()
|
||||||
|
const {
|
||||||
|
mutateAsync: upsertMutedWord,
|
||||||
|
variables: optimisticUpsert,
|
||||||
|
reset: resetUpsert,
|
||||||
|
} = useUpsertMutedWordsMutation()
|
||||||
|
const {
|
||||||
|
mutateAsync: removeMutedWord,
|
||||||
|
variables: optimisticRemove,
|
||||||
|
reset: resetRemove,
|
||||||
|
} = useRemoveMutedWordMutation()
|
||||||
|
|
||||||
|
const sanitizedTag = tag.replace(/^#/, '')
|
||||||
|
const isMuted = Boolean(
|
||||||
|
(preferences?.mutedWords?.find(
|
||||||
|
m => m.value === sanitizedTag && m.targets.includes('tag'),
|
||||||
|
) ??
|
||||||
|
optimisticUpsert?.find(
|
||||||
|
m => m.value === sanitizedTag && m.targets.includes('tag'),
|
||||||
|
)) &&
|
||||||
|
!(optimisticRemove?.value === sanitizedTag),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<Dialog.Outer control={control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
|
||||||
|
<Dialog.Inner label={_(msg`Tag menu: ${tag}`)}>
|
||||||
|
{isPreferencesLoading ? (
|
||||||
|
<View style={[a.w_full, a.align_center]}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_md,
|
||||||
|
a.border,
|
||||||
|
a.mb_md,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<Link
|
||||||
|
label={_(msg`Search for all posts with tag ${tag}`)}
|
||||||
|
to={makeSearchLink({query: tag})}
|
||||||
|
onPress={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
control.close(() => {
|
||||||
|
// @ts-ignore :ron_swanson: "I know more than you"
|
||||||
|
navigation.navigate('SearchTab', {
|
||||||
|
screen: 'Search',
|
||||||
|
params: {
|
||||||
|
q: tag,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return false
|
||||||
|
}}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_start,
|
||||||
|
a.gap_md,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_md,
|
||||||
|
]}>
|
||||||
|
<Search size="lg" style={[t.atoms.text_contrast_medium]} />
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="middle"
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_md,
|
||||||
|
a.font_bold,
|
||||||
|
native({top: 2}),
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
See{' '}
|
||||||
|
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||||
|
{tag}
|
||||||
|
</Text>{' '}
|
||||||
|
posts
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{authorHandle && !isInvalidHandle(authorHandle) && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Link
|
||||||
|
label={_(
|
||||||
|
msg`Search for all posts by @${authorHandle} with tag ${tag}`,
|
||||||
|
)}
|
||||||
|
to={makeSearchLink({query: tag, from: authorHandle})}
|
||||||
|
onPress={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
control.close(() => {
|
||||||
|
// @ts-ignore :ron_swanson: "I know more than you"
|
||||||
|
navigation.navigate('SearchTab', {
|
||||||
|
screen: 'Search',
|
||||||
|
params: {
|
||||||
|
q:
|
||||||
|
tag +
|
||||||
|
(authorHandle ? ` from:${authorHandle}` : ''),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return false
|
||||||
|
}}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_start,
|
||||||
|
a.gap_md,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_md,
|
||||||
|
]}>
|
||||||
|
<Person
|
||||||
|
size="lg"
|
||||||
|
style={[t.atoms.text_contrast_medium]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="middle"
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_md,
|
||||||
|
a.font_bold,
|
||||||
|
native({top: 2}),
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
See{' '}
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||||
|
{tag}
|
||||||
|
</Text>{' '}
|
||||||
|
posts by this user
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preferences ? (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={
|
||||||
|
isMuted
|
||||||
|
? _(msg`Unmute all ${tag} posts`)
|
||||||
|
: _(msg`Mute all ${tag} posts`)
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
control.close(() => {
|
||||||
|
if (isMuted) {
|
||||||
|
resetUpsert()
|
||||||
|
removeMutedWord({
|
||||||
|
value: sanitizedTag,
|
||||||
|
targets: ['tag'],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resetRemove()
|
||||||
|
upsertMutedWord([
|
||||||
|
{value: sanitizedTag, targets: ['tag']},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_start,
|
||||||
|
a.gap_md,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_md,
|
||||||
|
]}>
|
||||||
|
<Mute
|
||||||
|
size="lg"
|
||||||
|
style={[t.atoms.text_contrast_medium]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="middle"
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_md,
|
||||||
|
a.font_bold,
|
||||||
|
native({top: 2}),
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
|
||||||
|
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||||
|
{tag}
|
||||||
|
</Text>{' '}
|
||||||
|
<Trans>posts</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close this dialog`)}
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
onPress={() => control.close()}>
|
||||||
|
<ButtonText>Cancel</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Inner>
|
||||||
|
</Dialog.Outer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
127
src/components/TagMenu/index.web.tsx
Normal file
127
src/components/TagMenu/index.web.tsx
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||||
|
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||||
|
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
|
||||||
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {
|
||||||
|
usePreferencesQuery,
|
||||||
|
useUpsertMutedWordsMutation,
|
||||||
|
useRemoveMutedWordMutation,
|
||||||
|
} from '#/state/queries/preferences'
|
||||||
|
|
||||||
|
export function useTagMenuControl() {}
|
||||||
|
|
||||||
|
export function TagMenu({
|
||||||
|
children,
|
||||||
|
tag,
|
||||||
|
authorHandle,
|
||||||
|
}: React.PropsWithChildren<{
|
||||||
|
tag: string
|
||||||
|
authorHandle?: string
|
||||||
|
}>) {
|
||||||
|
const sanitizedTag = tag.replace(/^#/, '')
|
||||||
|
const {_} = useLingui()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const {data: preferences} = usePreferencesQuery()
|
||||||
|
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
||||||
|
useUpsertMutedWordsMutation()
|
||||||
|
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
|
||||||
|
useRemoveMutedWordMutation()
|
||||||
|
const isMuted = Boolean(
|
||||||
|
(preferences?.mutedWords?.find(
|
||||||
|
m => m.value === sanitizedTag && m.targets.includes('tag'),
|
||||||
|
) ??
|
||||||
|
optimisticUpsert?.find(
|
||||||
|
m => m.value === sanitizedTag && m.targets.includes('tag'),
|
||||||
|
)) &&
|
||||||
|
!(optimisticRemove?.value === sanitizedTag),
|
||||||
|
)
|
||||||
|
|
||||||
|
const dropdownItems = React.useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: _(msg`See ${tag} posts`),
|
||||||
|
onPress() {
|
||||||
|
navigation.navigate('Search', {
|
||||||
|
q: tag,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
testID: 'tagMenuSearch',
|
||||||
|
icon: {
|
||||||
|
ios: {
|
||||||
|
name: 'magnifyingglass',
|
||||||
|
},
|
||||||
|
android: '',
|
||||||
|
web: 'magnifying-glass',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authorHandle &&
|
||||||
|
!isInvalidHandle(authorHandle) && {
|
||||||
|
label: _(msg`See ${tag} posts by this user`),
|
||||||
|
onPress() {
|
||||||
|
navigation.navigate({
|
||||||
|
name: 'Search',
|
||||||
|
params: {
|
||||||
|
q: tag + (authorHandle ? ` from:${authorHandle}` : ''),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
testID: 'tagMenuSeachByUser',
|
||||||
|
icon: {
|
||||||
|
ios: {
|
||||||
|
name: 'magnifyingglass',
|
||||||
|
},
|
||||||
|
android: '',
|
||||||
|
web: ['far', 'user'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preferences && {
|
||||||
|
label: 'separator',
|
||||||
|
},
|
||||||
|
preferences && {
|
||||||
|
label: isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`),
|
||||||
|
onPress() {
|
||||||
|
if (isMuted) {
|
||||||
|
removeMutedWord({value: sanitizedTag, targets: ['tag']})
|
||||||
|
} else {
|
||||||
|
upsertMutedWord([{value: sanitizedTag, targets: ['tag']}])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
testID: 'tagMenuMute',
|
||||||
|
icon: {
|
||||||
|
ios: {
|
||||||
|
name: 'speaker.slash',
|
||||||
|
},
|
||||||
|
android: 'ic_menu_sort_alphabetically',
|
||||||
|
web: isMuted ? 'eye' : ['far', 'eye-slash'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
].filter(Boolean)
|
||||||
|
}, [
|
||||||
|
_,
|
||||||
|
authorHandle,
|
||||||
|
isMuted,
|
||||||
|
navigation,
|
||||||
|
preferences,
|
||||||
|
tag,
|
||||||
|
sanitizedTag,
|
||||||
|
upsertMutedWord,
|
||||||
|
removeMutedWord,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EventStopper>
|
||||||
|
<NativeDropdown
|
||||||
|
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
|
||||||
|
accessibilityHint=""
|
||||||
|
// @ts-ignore
|
||||||
|
items={dropdownItems}>
|
||||||
|
{children}
|
||||||
|
</NativeDropdown>
|
||||||
|
</EventStopper>
|
||||||
|
)
|
||||||
|
}
|
29
src/components/dialogs/Context.tsx
Normal file
29
src/components/dialogs/Context.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
|
||||||
|
type Control = Dialog.DialogOuterProps['control']
|
||||||
|
|
||||||
|
type ControlsContext = {
|
||||||
|
mutedWordsDialogControl: Control
|
||||||
|
}
|
||||||
|
|
||||||
|
const ControlsContext = React.createContext({
|
||||||
|
mutedWordsDialogControl: {} as Control,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useGlobalDialogsControlContext() {
|
||||||
|
return React.useContext(ControlsContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const mutedWordsDialogControl = Dialog.useDialogControl()
|
||||||
|
const ctx = React.useMemo(
|
||||||
|
() => ({mutedWordsDialogControl}),
|
||||||
|
[mutedWordsDialogControl],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
328
src/components/dialogs/MutedWords.tsx
Normal file
328
src/components/dialogs/MutedWords.tsx
Normal file
|
@ -0,0 +1,328 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {AppBskyActorDefs} from '@atproto/api'
|
||||||
|
|
||||||
|
import {
|
||||||
|
usePreferencesQuery,
|
||||||
|
useUpsertMutedWordsMutation,
|
||||||
|
useRemoveMutedWordMutation,
|
||||||
|
} from '#/state/queries/preferences'
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
import {atoms as a, useTheme, useBreakpoints, ViewStyleProp} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
|
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||||
|
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
|
||||||
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
|
|
||||||
|
export function MutedWordsDialog() {
|
||||||
|
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<MutedWordsInner control={control} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const {
|
||||||
|
isLoading: isPreferencesLoading,
|
||||||
|
data: preferences,
|
||||||
|
error: preferencesError,
|
||||||
|
} = usePreferencesQuery()
|
||||||
|
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
||||||
|
const [field, setField] = React.useState('')
|
||||||
|
const [options, setOptions] = React.useState(['content'])
|
||||||
|
const [_error, setError] = React.useState('')
|
||||||
|
|
||||||
|
const submit = React.useCallback(async () => {
|
||||||
|
const value = field.trim()
|
||||||
|
const targets = ['tag', options.includes('content') && 'content'].filter(
|
||||||
|
Boolean,
|
||||||
|
) as AppBskyActorDefs.MutedWord['targets']
|
||||||
|
|
||||||
|
if (!value || !targets.length) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addMutedWord([{value, targets}])
|
||||||
|
setField('')
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error(`Failed to save muted word`, {message: e.message})
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
}, [field, options, addMutedWord, setField])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
||||||
|
<Trans>Add muted words and tags</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>
|
||||||
|
Posts can be muted based on their text, their tags, or both.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.pb_lg]}>
|
||||||
|
<Dialog.Input
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="off"
|
||||||
|
label={_(msg`Enter a word or tag`)}
|
||||||
|
placeholder={_(msg`Enter a word or tag`)}
|
||||||
|
value={field}
|
||||||
|
onChangeText={setField}
|
||||||
|
onSubmitEditing={submit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Toggle.Group
|
||||||
|
label={_(msg`Toggle between muted word options.`)}
|
||||||
|
type="radio"
|
||||||
|
values={options}
|
||||||
|
onChange={setOptions}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.pt_sm,
|
||||||
|
a.pb_md,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
a.flex_wrap,
|
||||||
|
]}>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word in post text and tags`)}
|
||||||
|
name="content"
|
||||||
|
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.Label>
|
||||||
|
<Trans>Mute in text & tags</Trans>
|
||||||
|
</Toggle.Label>
|
||||||
|
</View>
|
||||||
|
<PageText size="sm" />
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word in tags only`)}
|
||||||
|
name="tag"
|
||||||
|
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.Label>
|
||||||
|
<Trans>Mute in tags only</Trans>
|
||||||
|
</Toggle.Label>
|
||||||
|
</View>
|
||||||
|
<Hashtag size="sm" />
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={isPending || !field}
|
||||||
|
label={_(msg`Add mute word for configured settings`)}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
style={[!gtMobile && [a.w_full, a.flex_0]]}
|
||||||
|
onPress={submit}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Add</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonIcon icon={isPending ? Loader : Plus} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Toggle.Group>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.italic,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
We recommend avoiding common words that appear in many posts, since
|
||||||
|
it can result in no posts being shown.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<View style={[a.pt_2xl]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
|
||||||
|
<Trans>Your muted words</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isPreferencesLoading ? (
|
||||||
|
<Loader />
|
||||||
|
) : preferencesError || !preferences ? (
|
||||||
|
<View
|
||||||
|
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
|
||||||
|
<Text style={[a.italic, t.atoms.text_contrast_high]}>
|
||||||
|
<Trans>
|
||||||
|
We're sorry, but we weren't able to load your muted words at
|
||||||
|
this time. Please try again.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : preferences.mutedWords.length ? (
|
||||||
|
[...preferences.mutedWords]
|
||||||
|
.reverse()
|
||||||
|
.map((word, i) => (
|
||||||
|
<MutedWordRow
|
||||||
|
key={word.value + i}
|
||||||
|
word={word}
|
||||||
|
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
|
||||||
|
<Text style={[a.italic, t.atoms.text_contrast_high]}>
|
||||||
|
<Trans>You haven't muted any words or tags yet</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isNative && <View style={{height: 20}} />}
|
||||||
|
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MutedWordRow({
|
||||||
|
style,
|
||||||
|
word,
|
||||||
|
}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
||||||
|
const control = Prompt.usePromptControl()
|
||||||
|
|
||||||
|
const remove = React.useCallback(async () => {
|
||||||
|
control.close()
|
||||||
|
removeMutedWord(word)
|
||||||
|
}, [removeMutedWord, word, control])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Prompt.Outer control={control}>
|
||||||
|
<Prompt.Title>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</Prompt.Title>
|
||||||
|
<Prompt.Description>
|
||||||
|
<Trans>
|
||||||
|
This will delete {word.value} from your muted words. You can always
|
||||||
|
add it back later.
|
||||||
|
</Trans>
|
||||||
|
</Prompt.Description>
|
||||||
|
<Prompt.Actions>
|
||||||
|
<Prompt.Cancel>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Nevermind</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Prompt.Cancel>
|
||||||
|
<Prompt.Action onPress={remove}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Remove</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Prompt.Action>
|
||||||
|
</Prompt.Actions>
|
||||||
|
</Prompt.Outer>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.py_md,
|
||||||
|
a.px_lg,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.rounded_md,
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
<Text style={[a.font_bold, t.atoms.text_contrast_high]}>
|
||||||
|
{word.value}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
|
||||||
|
{word.targets.map(target => (
|
||||||
|
<View
|
||||||
|
key={target}
|
||||||
|
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={_(msg`Remove mute word from your list`)}
|
||||||
|
size="tiny"
|
||||||
|
shape="round"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
onPress={() => control.open()}
|
||||||
|
style={[a.ml_sm]}>
|
||||||
|
<ButtonIcon icon={isPending ? Loader : X} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetToggle({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const t = useTheme()
|
||||||
|
const ctx = Toggle.useItemContext()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.gap_xs,
|
||||||
|
a.flex_1,
|
||||||
|
a.py_sm,
|
||||||
|
a.px_sm,
|
||||||
|
gtMobile && a.px_md,
|
||||||
|
a.rounded_sm,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100,
|
||||||
|
ctx.selected && [
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ctx.disabled && {
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -72,7 +72,7 @@ export function Root({children, isInvalid = false}: RootProps) {
|
||||||
return (
|
return (
|
||||||
<Context.Provider value={context}>
|
<Context.Provider value={context}>
|
||||||
<View
|
<View
|
||||||
style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
|
style={[a.flex_row, a.align_center, a.relative, a.flex_1, a.px_md]}
|
||||||
{...web({
|
{...web({
|
||||||
onClick: () => inputRef.current?.focus(),
|
onClick: () => inputRef.current?.focus(),
|
||||||
onMouseOver: onHoverIn,
|
onMouseOver: onHoverIn,
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {HITSLOP_10} from 'lib/constants'
|
||||||
import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
|
import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
|
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
|
||||||
|
|
||||||
export type ItemState = {
|
export type ItemState = {
|
||||||
name: string
|
name: string
|
||||||
|
@ -331,15 +332,14 @@ export function createSharedToggleStyles({
|
||||||
export function Checkbox() {
|
export function Checkbox() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
|
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
|
||||||
const {baseStyles, baseHoverStyles, indicatorStyles} =
|
const {baseStyles, baseHoverStyles} = createSharedToggleStyles({
|
||||||
createSharedToggleStyles({
|
theme: t,
|
||||||
theme: t,
|
hovered,
|
||||||
hovered,
|
focused,
|
||||||
focused,
|
selected,
|
||||||
selected,
|
disabled,
|
||||||
disabled,
|
isInvalid,
|
||||||
isInvalid,
|
})
|
||||||
})
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
@ -355,21 +355,7 @@ export function Checkbox() {
|
||||||
baseStyles,
|
baseStyles,
|
||||||
hovered || focused ? baseHoverStyles : {},
|
hovered || focused ? baseHoverStyles : {},
|
||||||
]}>
|
]}>
|
||||||
{selected ? (
|
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
a.rounded_2xs,
|
|
||||||
{height: 12, width: 12},
|
|
||||||
selected
|
|
||||||
? {
|
|
||||||
backgroundColor: t.palette.primary_500,
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
indicatorStyles,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||||
export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z',
|
path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const CheckThick_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z',
|
||||||
|
})
|
||||||
|
|
5
src/components/icons/Clipboard.tsx
Normal file
5
src/components/icons/Clipboard.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z',
|
||||||
|
})
|
|
@ -1,5 +1,5 @@
|
||||||
import {createSinglePathSVG} from './TEMPLATE'
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
path: 'M17 16H21.1456C20.8246 11.4468 17.7199 9.48509 15.0001 10.1147M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4ZM18.5 4.5C18.5 5.88071 17.3807 7 16 7C14.6193 7 13.5 5.88071 13.5 4.5C13.5 3.11929 14.6193 2 16 2C17.3807 2 18.5 3.11929 18.5 4.5ZM1 17H13C12.3421 7.66667 1.65792 7.66667 1 17Z',
|
path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z',
|
||||||
})
|
})
|
||||||
|
|
5
src/components/icons/MagnifyingGlass2.tsx
Normal file
5
src/components/icons/MagnifyingGlass2.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z',
|
||||||
|
})
|
5
src/components/icons/Mute.tsx
Normal file
5
src/components/icons/Mute.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Mute_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z',
|
||||||
|
})
|
5
src/components/icons/PageText.tsx
Normal file
5
src/components/icons/PageText.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const PageText_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z',
|
||||||
|
})
|
5
src/components/icons/Person.tsx
Normal file
5
src/components/icons/Person.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z',
|
||||||
|
})
|
578
src/lib/__tests__/moderatePost_wrapped.test.ts
Normal file
578
src/lib/__tests__/moderatePost_wrapped.test.ts
Normal file
|
@ -0,0 +1,578 @@
|
||||||
|
import {describe, it, expect} from '@jest/globals'
|
||||||
|
import {RichText} from '@atproto/api'
|
||||||
|
|
||||||
|
import {hasMutedWord} from '../moderatePost_wrapped'
|
||||||
|
|
||||||
|
describe(`hasMutedWord`, () => {
|
||||||
|
describe(`tags`, () => {
|
||||||
|
it(`match: outline tag`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `This is a post #inlineTag`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'outlineTag', targets: ['tag']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
['outlineTag'],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: inline tag`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `This is a post #inlineTag`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'inlineTag', targets: ['tag']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
['outlineTag'],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: content target matches inline tag`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `This is a post #inlineTag`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'inlineTag', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
['outlineTag'],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`no match: only tag targets`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `This is a post`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'inlineTag', targets: ['tag']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`early exits`, () => {
|
||||||
|
it(`match: single character 希`, () => {
|
||||||
|
/**
|
||||||
|
* @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c
|
||||||
|
*/
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `改善希望です`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: '希', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`no match: long muted word, short post`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `hey`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'politics', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: exact text`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `javascript`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'javascript', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`general content`, () => {
|
||||||
|
it(`match: word within post`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `This is a post about javascript`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'javascript', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`no match: partial word`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Use your brain, Eric`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'ai', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: multiline`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Use your\n\tbrain, Eric`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'brain', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: :)`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `So happy :)`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `:)`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`punctuation semi-fuzzy`, () => {
|
||||||
|
describe(`yay!`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `We're federating, yay!`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
it(`match: yay!`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'yay!', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: yay`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'yay', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`y!ppee!!`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `We're federating, y!ppee!!`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
it(`match: y!ppee`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'y!ppee', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// single exclamation point, source has double
|
||||||
|
it(`no match: y!ppee!`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'y!ppee!', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`Why so S@assy?`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Why so S@assy?`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
it(`match: S@assy`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'S@assy', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: s@assy`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 's@assy', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`New York Times`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `New York Times`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
// case insensitive
|
||||||
|
it(`match: new york times`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'new york times', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`!command`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Idk maybe a bot !command`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
it(`match: !command`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `!command`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: command`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `command`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`no match: !command`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Idk maybe a bot command`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `!command`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`e/acc`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `I'm e/acc pilled`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
it(`match: e/acc`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `e/acc`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: acc`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `acc`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`super-bad`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `I'm super-bad`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
it(`match: super-bad`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `super-bad`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: super`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `super`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: super bad`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `super bad`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: superbad`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `superbad`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`idk_what_this_would_be`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Weird post with idk_what_this_would_be`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
it(`match: idk what this would be`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `idk what this would be`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`no match: idk what this would be for`, () => {
|
||||||
|
// extra word
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `idk what this would be for`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: idk`, () => {
|
||||||
|
// extra word
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `idk`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: idkwhatthiswouldbe`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `idkwhatthiswouldbe`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`parentheses`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Post with context(iykyk)`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
it(`match: context(iykyk)`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `context(iykyk)`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: context`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `context`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: iykyk`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `iykyk`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: (iykyk)`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `(iykyk)`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`🦋`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Post with 🦋`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
it(`match: 🦋`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `🦋`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`phrases`, () => {
|
||||||
|
describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `I like turtles, or how I learned to stop worrying and love the internet.`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
it(`match: stop worrying`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'stop worrying', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: turtles, or how`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'turtles, or how', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -104,18 +104,18 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||||
|
|
||||||
// add image embed if present
|
// add image embed if present
|
||||||
if (opts.images?.length) {
|
if (opts.images?.length) {
|
||||||
logger.info(`Uploading images`, {
|
logger.debug(`Uploading images`, {
|
||||||
count: opts.images.length,
|
count: opts.images.length,
|
||||||
})
|
})
|
||||||
|
|
||||||
const images: AppBskyEmbedImages.Image[] = []
|
const images: AppBskyEmbedImages.Image[] = []
|
||||||
for (const image of opts.images) {
|
for (const image of opts.images) {
|
||||||
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
||||||
logger.info(`Compressing image`)
|
logger.debug(`Compressing image`)
|
||||||
await image.compress()
|
await image.compress()
|
||||||
const path = image.compressed?.path ?? image.path
|
const path = image.compressed?.path ?? image.path
|
||||||
const {width, height} = image.compressed || image
|
const {width, height} = image.compressed || image
|
||||||
logger.info(`Uploading image`)
|
logger.debug(`Uploading image`)
|
||||||
const res = await uploadBlob(agent, path, 'image/jpeg')
|
const res = await uploadBlob(agent, path, 'image/jpeg')
|
||||||
images.push({
|
images.push({
|
||||||
image: res.data.blob,
|
image: res.data.blob,
|
||||||
|
|
|
@ -2,18 +2,122 @@ import {
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
moderatePost,
|
moderatePost,
|
||||||
|
AppBskyActorDefs,
|
||||||
|
AppBskyFeedPost,
|
||||||
|
AppBskyRichtextFacet,
|
||||||
|
AppBskyEmbedImages,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
type ModeratePost = typeof moderatePost
|
type ModeratePost = typeof moderatePost
|
||||||
type Options = Parameters<ModeratePost>[1] & {
|
type Options = Parameters<ModeratePost>[1] & {
|
||||||
hiddenPosts?: string[]
|
hiddenPosts?: string[]
|
||||||
|
mutedWords?: AppBskyActorDefs.MutedWord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGEX = {
|
||||||
|
LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu,
|
||||||
|
ESCAPE: /[[\]{}()*+?.\\^$|\s]/g,
|
||||||
|
SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g,
|
||||||
|
WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMutedWord(
|
||||||
|
mutedWords: AppBskyActorDefs.MutedWord[],
|
||||||
|
text: string,
|
||||||
|
facets?: AppBskyRichtextFacet.Main[],
|
||||||
|
outlineTags?: string[],
|
||||||
|
) {
|
||||||
|
const tags = ([] as string[])
|
||||||
|
.concat(outlineTags || [])
|
||||||
|
.concat(
|
||||||
|
facets
|
||||||
|
?.filter(facet => {
|
||||||
|
return facet.features.find(feature =>
|
||||||
|
AppBskyRichtextFacet.isTag(feature),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(t => t.features[0].tag as string) || [],
|
||||||
|
)
|
||||||
|
.map(t => t.toLowerCase())
|
||||||
|
|
||||||
|
for (const mute of mutedWords) {
|
||||||
|
const mutedWord = mute.value.toLowerCase()
|
||||||
|
const postText = text.toLowerCase()
|
||||||
|
|
||||||
|
// `content` applies to tags as well
|
||||||
|
if (tags.includes(mutedWord)) return true
|
||||||
|
// rest of the checks are for `content` only
|
||||||
|
if (!mute.targets.includes('content')) continue
|
||||||
|
// single character, has to use includes
|
||||||
|
if (mutedWord.length === 1 && postText.includes(mutedWord)) return true
|
||||||
|
// too long
|
||||||
|
if (mutedWord.length > postText.length) continue
|
||||||
|
// exact match
|
||||||
|
if (mutedWord === postText) return true
|
||||||
|
// any muted phrase with space or punctuation
|
||||||
|
if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord))
|
||||||
|
return true
|
||||||
|
|
||||||
|
// check individual character groups
|
||||||
|
const words = postText.split(REGEX.WORD_BOUNDARY)
|
||||||
|
for (const word of words) {
|
||||||
|
if (word === mutedWord) return true
|
||||||
|
|
||||||
|
// compare word without leading/trailing punctuation, but allow internal
|
||||||
|
// punctuation (such as `s@ssy`)
|
||||||
|
const wordTrimmedPunctuation = word.replace(
|
||||||
|
REGEX.LEADING_TRAILING_PUNCTUATION,
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (mutedWord === wordTrimmedPunctuation) return true
|
||||||
|
if (mutedWord.length > wordTrimmedPunctuation.length) continue
|
||||||
|
|
||||||
|
// handle hyphenated, slash separated words, etc
|
||||||
|
if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) {
|
||||||
|
// check against full normalized phrase
|
||||||
|
const wordNormalizedSeparators = wordTrimmedPunctuation.replace(
|
||||||
|
REGEX.SEPARATORS,
|
||||||
|
' ',
|
||||||
|
)
|
||||||
|
const mutedWordNormalizedSeparators = mutedWord.replace(
|
||||||
|
REGEX.SEPARATORS,
|
||||||
|
' ',
|
||||||
|
)
|
||||||
|
// hyphenated (or other sep) to spaced words
|
||||||
|
if (wordNormalizedSeparators === mutedWordNormalizedSeparators)
|
||||||
|
return true
|
||||||
|
|
||||||
|
/* Disabled for now e.g. `super-cool` to `supercool`
|
||||||
|
const wordNormalizedCompressed = wordNormalizedSeparators.replace(
|
||||||
|
REGEX.WORD_BOUNDARY,
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
const mutedWordNormalizedCompressed =
|
||||||
|
mutedWordNormalizedSeparators.replace(/\s+?/g, '')
|
||||||
|
// hyphenated (or other sep) to non-hyphenated contiguous word
|
||||||
|
if (mutedWordNormalizedCompressed === wordNormalizedCompressed)
|
||||||
|
return true
|
||||||
|
*/
|
||||||
|
|
||||||
|
// then individual parts of separated phrases/words
|
||||||
|
const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS)
|
||||||
|
for (const wp of wordParts) {
|
||||||
|
// still retain internal punctuation
|
||||||
|
if (wp === mutedWord) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moderatePost_wrapped(
|
export function moderatePost_wrapped(
|
||||||
subject: Parameters<ModeratePost>[0],
|
subject: Parameters<ModeratePost>[0],
|
||||||
opts: Options,
|
opts: Options,
|
||||||
) {
|
) {
|
||||||
const {hiddenPosts = [], ...options} = opts
|
const {hiddenPosts = [], mutedWords = [], ...options} = opts
|
||||||
const moderations = moderatePost(subject, options)
|
const moderations = moderatePost(subject, options)
|
||||||
|
|
||||||
if (hiddenPosts.includes(subject.uri)) {
|
if (hiddenPosts.includes(subject.uri)) {
|
||||||
|
@ -29,15 +133,65 @@ export function moderatePost_wrapped(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (AppBskyFeedPost.isRecord(subject.record)) {
|
||||||
|
let muted = hasMutedWord(
|
||||||
|
mutedWords,
|
||||||
|
subject.record.text,
|
||||||
|
subject.record.facets || [],
|
||||||
|
subject.record.tags || [],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
subject.record.embed &&
|
||||||
|
AppBskyEmbedImages.isMain(subject.record.embed)
|
||||||
|
) {
|
||||||
|
for (const image of subject.record.embed.images) {
|
||||||
|
muted = muted || hasMutedWord(mutedWords, image.alt, [], [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (muted) {
|
||||||
|
moderations.content.filter = true
|
||||||
|
moderations.content.blur = true
|
||||||
|
if (!moderations.content.cause) {
|
||||||
|
moderations.content.cause = {
|
||||||
|
// @ts-ignore Temporary extension to the moderation system -prf
|
||||||
|
type: 'muted-word',
|
||||||
|
source: {type: 'user'},
|
||||||
|
priority: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (subject.embed) {
|
if (subject.embed) {
|
||||||
let embedHidden = false
|
let embedHidden = false
|
||||||
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
|
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
|
||||||
embedHidden = hiddenPosts.includes(subject.embed.record.uri)
|
embedHidden = hiddenPosts.includes(subject.embed.record.uri)
|
||||||
|
|
||||||
|
if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
|
||||||
|
embedHidden =
|
||||||
|
embedHidden ||
|
||||||
|
hasMutedWord(
|
||||||
|
mutedWords,
|
||||||
|
subject.embed.record.value.text,
|
||||||
|
subject.embed.record.value.facets,
|
||||||
|
subject.embed.record.value.tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) {
|
||||||
|
for (const image of subject.embed.record.value.embed.images) {
|
||||||
|
embedHidden =
|
||||||
|
embedHidden || hasMutedWord(mutedWords, image.alt, [], [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
|
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
|
||||||
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
|
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
|
||||||
) {
|
) {
|
||||||
|
// TODO what
|
||||||
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
|
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
|
||||||
}
|
}
|
||||||
if (embedHidden) {
|
if (embedHidden) {
|
||||||
|
|
|
@ -67,6 +67,13 @@ export function describeModerationCause(
|
||||||
description: 'You have hidden this post',
|
description: 'You have hidden this post',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// @ts-ignore Temporary extension to the moderation system -prf
|
||||||
|
if (cause.type === 'muted-word') {
|
||||||
|
return {
|
||||||
|
name: 'Post hidden by muted word',
|
||||||
|
description: `You've chosen to hide a word or tag within this post.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
return cause.labelDef.strings[context].en
|
return cause.labelDef.strings[context].en
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,3 +25,13 @@ export function makeCustomFeedLink(
|
||||||
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
|
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
|
||||||
return [`/profile`, did, 'lists', rkey, ...segments].join('/')
|
return [`/profile`, did, 'lists', rkey, ...segments].join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function makeTagLink(did: string) {
|
||||||
|
return `/search?q=${encodeURIComponent(did)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeSearchLink(props: {query: string; from?: 'me' | string}) {
|
||||||
|
return `/search?q=${encodeURIComponent(
|
||||||
|
props.query + (props.from ? ` from:${props.from}` : ''),
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@ export type CommonNavigatorParams = {
|
||||||
PreferencesFollowingFeed: undefined
|
PreferencesFollowingFeed: undefined
|
||||||
PreferencesThreads: undefined
|
PreferencesThreads: undefined
|
||||||
PreferencesExternalEmbeds: undefined
|
PreferencesExternalEmbeds: undefined
|
||||||
|
Search: {q?: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||||
|
|
|
@ -232,7 +232,7 @@ export function reducer(
|
||||||
})
|
})
|
||||||
|
|
||||||
if (s.activeStep !== state.activeStep) {
|
if (s.activeStep !== state.activeStep) {
|
||||||
logger.info(`onboarding: step changed`, {activeStep: state.activeStep})
|
logger.debug(`onboarding: step changed`, {activeStep: state.activeStep})
|
||||||
}
|
}
|
||||||
|
|
||||||
return state
|
return state
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {DialogControlProps} from '#/components/Dialog'
|
import {DialogControlProps} from '#/components/Dialog'
|
||||||
|
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
|
||||||
|
|
||||||
const DialogContext = React.createContext<{
|
const DialogContext = React.createContext<{
|
||||||
activeDialogs: React.MutableRefObject<
|
activeDialogs: React.MutableRefObject<
|
||||||
|
@ -37,7 +38,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
return (
|
return (
|
||||||
<DialogContext.Provider value={context}>
|
<DialogContext.Provider value={context}>
|
||||||
<DialogControlContext.Provider value={controls}>
|
<DialogControlContext.Provider value={controls}>
|
||||||
{children}
|
<GlobalDialogsProvider>{children}</GlobalDialogsProvider>
|
||||||
</DialogControlContext.Provider>
|
</DialogControlContext.Provider>
|
||||||
</DialogContext.Provider>
|
</DialogContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
@ -26,7 +26,7 @@ test('migrate: fresh install', async () => {
|
||||||
|
|
||||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
||||||
expect(read).toHaveBeenCalledTimes(1)
|
expect(read).toHaveBeenCalledTimes(1)
|
||||||
expect(logger.info).toHaveBeenCalledWith(
|
expect(logger.debug).toHaveBeenCalledWith(
|
||||||
'persisted state: no migration needed',
|
'persisted state: no migration needed',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -38,7 +38,7 @@ test('migrate: fresh install, existing new storage', async () => {
|
||||||
|
|
||||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
||||||
expect(read).toHaveBeenCalledTimes(1)
|
expect(read).toHaveBeenCalledTimes(1)
|
||||||
expect(logger.info).toHaveBeenCalledWith(
|
expect(logger.debug).toHaveBeenCalledWith(
|
||||||
'persisted state: no migration needed',
|
'persisted state: no migration needed',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -68,7 +68,7 @@ test('migrate: has legacy data', async () => {
|
||||||
await migrate()
|
await migrate()
|
||||||
|
|
||||||
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
|
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
|
||||||
expect(logger.info).toHaveBeenCalledWith(
|
expect(logger.debug).toHaveBeenCalledWith(
|
||||||
'persisted state: migrated legacy storage',
|
'persisted state: migrated legacy storage',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -19,7 +19,7 @@ const _emitter = new EventEmitter()
|
||||||
* the Provider.
|
* the Provider.
|
||||||
*/
|
*/
|
||||||
export async function init() {
|
export async function init() {
|
||||||
logger.info('persisted state: initializing')
|
logger.debug('persisted state: initializing')
|
||||||
|
|
||||||
broadcast.onmessage = onBroadcastMessage
|
broadcast.onmessage = onBroadcastMessage
|
||||||
|
|
||||||
|
@ -27,11 +27,11 @@ export async function init() {
|
||||||
await migrate() // migrate old store
|
await migrate() // migrate old store
|
||||||
const stored = await store.read() // check for new store
|
const stored = await store.read() // check for new store
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
logger.info('persisted state: initializing default storage')
|
logger.debug('persisted state: initializing default storage')
|
||||||
await store.write(defaults) // opt: init new store
|
await store.write(defaults) // opt: init new store
|
||||||
}
|
}
|
||||||
_state = stored || defaults // return new store
|
_state = stored || defaults // return new store
|
||||||
logger.log('persisted state: initialized')
|
logger.debug('persisted state: initialized')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('persisted state: failed to load root state from storage', {
|
logger.error('persisted state: failed to load root state from storage', {
|
||||||
message: e,
|
message: e,
|
||||||
|
|
|
@ -121,7 +121,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
|
||||||
* local storage AND old storage exists.
|
* local storage AND old storage exists.
|
||||||
*/
|
*/
|
||||||
export async function migrate() {
|
export async function migrate() {
|
||||||
logger.info('persisted state: check need to migrate')
|
logger.debug('persisted state: check need to migrate')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rawLegacyData = await AsyncStorage.getItem(
|
const rawLegacyData = await AsyncStorage.getItem(
|
||||||
|
@ -131,7 +131,7 @@ export async function migrate() {
|
||||||
const alreadyMigrated = Boolean(newData)
|
const alreadyMigrated = Boolean(newData)
|
||||||
|
|
||||||
if (!alreadyMigrated && rawLegacyData) {
|
if (!alreadyMigrated && rawLegacyData) {
|
||||||
logger.info('persisted state: migrating legacy storage')
|
logger.debug('persisted state: migrating legacy storage')
|
||||||
|
|
||||||
const legacyData = JSON.parse(rawLegacyData)
|
const legacyData = JSON.parse(rawLegacyData)
|
||||||
const newData = transform(legacyData)
|
const newData = transform(legacyData)
|
||||||
|
@ -139,14 +139,14 @@ export async function migrate() {
|
||||||
|
|
||||||
if (validate.success) {
|
if (validate.success) {
|
||||||
await write(newData)
|
await write(newData)
|
||||||
logger.info('persisted state: migrated legacy storage')
|
logger.debug('persisted state: migrated legacy storage')
|
||||||
} else {
|
} else {
|
||||||
logger.error('persisted state: legacy data failed validation', {
|
logger.error('persisted state: legacy data failed validation', {
|
||||||
message: validate.error,
|
message: validate.error,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info('persisted state: no migration needed')
|
logger.debug('persisted state: no migration needed')
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error(e, {
|
logger.error(e, {
|
||||||
|
|
|
@ -49,4 +49,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
|
||||||
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
|
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
|
||||||
userAge: 13, // TODO(pwi)
|
userAge: 13, // TODO(pwi)
|
||||||
interests: {tags: []},
|
interests: {tags: []},
|
||||||
|
mutedWords: [],
|
||||||
|
hiddenPosts: [],
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
import {useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
|
import {
|
||||||
|
LabelPreference,
|
||||||
|
BskyFeedViewPreference,
|
||||||
|
AppBskyActorDefs,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {track} from '#/lib/analytics/analytics'
|
import {track} from '#/lib/analytics/analytics'
|
||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
|
@ -108,6 +112,7 @@ export function useModerationOpts() {
|
||||||
return {
|
return {
|
||||||
...moderationOpts,
|
...moderationOpts,
|
||||||
hiddenPosts,
|
hiddenPosts,
|
||||||
|
mutedWords: prefs.data.mutedWords || [],
|
||||||
}
|
}
|
||||||
}, [currentAccount?.did, prefs.data, hiddenPosts])
|
}, [currentAccount?.did, prefs.data, hiddenPosts])
|
||||||
return opts
|
return opts
|
||||||
|
@ -278,3 +283,45 @@ export function useUnpinFeedMutation() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpsertMutedWordsMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
|
||||||
|
await getAgent().upsertMutedWords(mutedWords)
|
||||||
|
// triggers a refetch
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: preferencesQueryKey,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateMutedWordMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
|
||||||
|
await getAgent().updateMutedWord(mutedWord)
|
||||||
|
// triggers a refetch
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: preferencesQueryKey,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveMutedWordMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
|
||||||
|
await getAgent().removeMutedWord(mutedWord)
|
||||||
|
// triggers a refetch
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: preferencesQueryKey,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -133,7 +133,7 @@ function createPersistSessionHandler(
|
||||||
accessJwt: session?.accessJwt,
|
accessJwt: session?.accessJwt,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`session: persistSession`, {
|
logger.debug(`session: persistSession`, {
|
||||||
event,
|
event,
|
||||||
deactivated: refreshedAccount.deactivated,
|
deactivated: refreshedAccount.deactivated,
|
||||||
})
|
})
|
||||||
|
@ -320,7 +320,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
)
|
)
|
||||||
|
|
||||||
const logout = React.useCallback<ApiContext['logout']>(async () => {
|
const logout = React.useCallback<ApiContext['logout']>(async () => {
|
||||||
logger.info(`session: logout`)
|
logger.debug(`session: logout`)
|
||||||
clearCurrentAccount()
|
clearCurrentAccount()
|
||||||
setStateAndPersist(s => {
|
setStateAndPersist(s => {
|
||||||
return {
|
return {
|
||||||
|
@ -374,7 +374,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canReusePrevSession) {
|
if (canReusePrevSession) {
|
||||||
logger.info(`session: attempting to reuse previous session`)
|
logger.debug(`session: attempting to reuse previous session`)
|
||||||
|
|
||||||
agent.session = prevSession
|
agent.session = prevSession
|
||||||
__globalAgent = agent
|
__globalAgent = agent
|
||||||
|
@ -384,7 +384,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
if (prevSession.deactivated) {
|
if (prevSession.deactivated) {
|
||||||
// don't attempt to resume
|
// don't attempt to resume
|
||||||
// use will be taken to the deactivated screen
|
// use will be taken to the deactivated screen
|
||||||
logger.info(`session: reusing session for deactivated account`)
|
logger.debug(`session: reusing session for deactivated account`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -410,7 +410,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
__globalAgent = PUBLIC_BSKY_AGENT
|
__globalAgent = PUBLIC_BSKY_AGENT
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
logger.info(`session: attempting to resume using previous session`)
|
logger.debug(`session: attempting to resume using previous session`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const freshAccount = await resumeSessionWithFreshAccount()
|
const freshAccount = await resumeSessionWithFreshAccount()
|
||||||
|
@ -431,7 +431,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
|
async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
|
||||||
logger.info(`session: resumeSessionWithFreshAccount`)
|
logger.debug(`session: resumeSessionWithFreshAccount`)
|
||||||
|
|
||||||
await networkRetry(1, () => agent.resumeSession(prevSession))
|
await networkRetry(1, () => agent.resumeSession(prevSession))
|
||||||
|
|
||||||
|
@ -552,11 +552,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate(() => {
|
||||||
const session = persisted.get('session')
|
const session = persisted.get('session')
|
||||||
|
|
||||||
logger.info(`session: persisted onUpdate`, {})
|
logger.debug(`session: persisted onUpdate`, {})
|
||||||
|
|
||||||
if (session.currentAccount && session.currentAccount.refreshJwt) {
|
if (session.currentAccount && session.currentAccount.refreshJwt) {
|
||||||
if (session.currentAccount?.did !== state.currentAccount?.did) {
|
if (session.currentAccount?.did !== state.currentAccount?.did) {
|
||||||
logger.info(`session: persisted onUpdate, switching accounts`, {
|
logger.debug(`session: persisted onUpdate, switching accounts`, {
|
||||||
from: {
|
from: {
|
||||||
did: state.currentAccount?.did,
|
did: state.currentAccount?.did,
|
||||||
handle: state.currentAccount?.handle,
|
handle: state.currentAccount?.handle,
|
||||||
|
@ -569,7 +569,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
|
|
||||||
initSession(session.currentAccount)
|
initSession(session.currentAccount)
|
||||||
} else {
|
} else {
|
||||||
logger.info(`session: persisted onUpdate, updating session`, {})
|
logger.debug(`session: persisted onUpdate, updating session`, {})
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Use updated session in this tab's agent. Do not call
|
* Use updated session in this tab's agent. Do not call
|
||||||
|
|
|
@ -133,8 +133,8 @@ function IsValidIcon({valid}: {valid: boolean}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return <Check size="md" style={{color: t.palette.negative_500}} />
|
return <Times size="md" style={{color: t.palette.negative_500}} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Times size="md" style={{color: t.palette.positive_700}} />
|
return <Check size="md" style={{color: t.palette.positive_700}} />
|
||||||
}
|
}
|
||||||
|
|
|
@ -107,7 +107,7 @@ export const LoginForm = ({
|
||||||
const errMsg = e.toString()
|
const errMsg = e.toString()
|
||||||
setIsProcessing(false)
|
setIsProcessing(false)
|
||||||
if (errMsg.includes('Authentication Required')) {
|
if (errMsg.includes('Authentication Required')) {
|
||||||
logger.info('Failed to login due to invalid credentials', {
|
logger.debug('Failed to login due to invalid credentials', {
|
||||||
error: errMsg,
|
error: errMsg,
|
||||||
})
|
})
|
||||||
setError(_(msg`Invalid username or password`))
|
setError(_(msg`Invalid username or password`))
|
||||||
|
|
|
@ -190,12 +190,11 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||||
let i = 0
|
let i = 0
|
||||||
|
|
||||||
return Array.from(richtext.segments()).map(segment => {
|
return Array.from(richtext.segments()).map(segment => {
|
||||||
const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0])
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={i++}
|
key={i++}
|
||||||
style={[
|
style={[
|
||||||
segment.facet && !isTag ? pal.link : pal.text,
|
segment.facet ? pal.link : pal.text,
|
||||||
styles.textInputFormatting,
|
styles.textInputFormatting,
|
||||||
]}>
|
]}>
|
||||||
{segment.text}
|
{segment.text}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import {Portal} from '#/components/Portal'
|
||||||
import {Text} from '../../util/text/Text'
|
import {Text} from '../../util/text/Text'
|
||||||
import {Trans} from '@lingui/macro'
|
import {Trans} from '@lingui/macro'
|
||||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||||
|
import {TagDecorator} from './web/TagDecorator'
|
||||||
|
|
||||||
export interface TextInputRef {
|
export interface TextInputRef {
|
||||||
focus: () => void
|
focus: () => void
|
||||||
|
@ -67,6 +68,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||||
() => [
|
() => [
|
||||||
Document,
|
Document,
|
||||||
LinkDecorator,
|
LinkDecorator,
|
||||||
|
TagDecorator,
|
||||||
Mention.configure({
|
Mention.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: 'mention',
|
class: 'mention',
|
||||||
|
|
83
src/view/com/composer/text-input/web/TagDecorator.ts
Normal file
83
src/view/com/composer/text-input/web/TagDecorator.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
/**
|
||||||
|
* TipTap is a stateful rich-text editor, which is extremely useful
|
||||||
|
* when you _want_ it to be stateful formatting such as bold and italics.
|
||||||
|
*
|
||||||
|
* However we also use "stateless" behaviors, specifically for URLs
|
||||||
|
* where the text itself drives the formatting.
|
||||||
|
*
|
||||||
|
* This plugin uses a regex to detect URIs and then applies
|
||||||
|
* link decorations (a <span> with the "autolink") class. That avoids
|
||||||
|
* adding any stateful formatting to TipTap's document model.
|
||||||
|
*
|
||||||
|
* We then run the URI detection again when constructing the
|
||||||
|
* RichText object from TipTap's output and merge their features into
|
||||||
|
* the facet-set.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Mark} from '@tiptap/core'
|
||||||
|
import {Plugin, PluginKey} from '@tiptap/pm/state'
|
||||||
|
import {Node as ProsemirrorNode} from '@tiptap/pm/model'
|
||||||
|
import {Decoration, DecorationSet} from '@tiptap/pm/view'
|
||||||
|
|
||||||
|
function getDecorations(doc: ProsemirrorNode) {
|
||||||
|
const decorations: Decoration[] = []
|
||||||
|
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (node.isText && node.text) {
|
||||||
|
const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g
|
||||||
|
const textContent = node.textContent
|
||||||
|
|
||||||
|
let match
|
||||||
|
while ((match = regex.exec(textContent))) {
|
||||||
|
const [matchedString, tag] = match
|
||||||
|
|
||||||
|
if (tag.length > 66) continue
|
||||||
|
|
||||||
|
const [trailingPunc = ''] = tag.match(/\p{P}+$/u) || []
|
||||||
|
|
||||||
|
const from = match.index + matchedString.indexOf(tag)
|
||||||
|
const to = from + (tag.length - trailingPunc.length)
|
||||||
|
|
||||||
|
decorations.push(
|
||||||
|
Decoration.inline(pos + from, pos + to, {
|
||||||
|
class: 'autolink',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagDecoratorPlugin: Plugin = new Plugin({
|
||||||
|
key: new PluginKey('link-decorator'),
|
||||||
|
|
||||||
|
state: {
|
||||||
|
init: (_, {doc}) => getDecorations(doc),
|
||||||
|
apply: (transaction, decorationSet) => {
|
||||||
|
if (transaction.docChanged) {
|
||||||
|
return getDecorations(transaction.doc)
|
||||||
|
}
|
||||||
|
return decorationSet.map(transaction.mapping, transaction.doc)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return tagDecoratorPlugin.getState(state)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TagDecorator = Mark.create({
|
||||||
|
name: 'tag-decorator',
|
||||||
|
priority: 1000,
|
||||||
|
keepOnSplit: false,
|
||||||
|
inclusive() {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [tagDecoratorPlugin]
|
||||||
|
},
|
||||||
|
})
|
|
@ -200,21 +200,12 @@ export function FeedPage({
|
||||||
function useHeaderOffset() {
|
function useHeaderOffset() {
|
||||||
const {isDesktop, isTablet} = useWebMediaQueries()
|
const {isDesktop, isTablet} = useWebMediaQueries()
|
||||||
const {fontScale} = useWindowDimensions()
|
const {fontScale} = useWindowDimensions()
|
||||||
const {hasSession} = useSession()
|
|
||||||
if (isDesktop || isTablet) {
|
if (isDesktop || isTablet) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if (hasSession) {
|
const navBarHeight = 42
|
||||||
const navBarPad = 16
|
const tabBarPad = 10 + 10 + 3 // padding + border
|
||||||
const navBarText = 21 * fontScale
|
const normalLineHeight = 1.2
|
||||||
const tabBarPad = 20 + 3 // nav bar padding + border
|
const tabBarText = 16 * normalLineHeight * fontScale
|
||||||
const tabBarText = 16 * fontScale
|
return navBarHeight + tabBarPad + tabBarText
|
||||||
const magic = 7 * fontScale
|
|
||||||
return navBarPad + navBarText + tabBarPad + tabBarText + magic
|
|
||||||
} else {
|
|
||||||
const navBarPad = 16
|
|
||||||
const navBarText = 21 * fontScale
|
|
||||||
const magic = 4 * fontScale
|
|
||||||
return navBarPad + navBarText + magic
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@ import {Text} from '../util/text/Text'
|
||||||
import {PressableWithHover} from '../util/PressableWithHover'
|
import {PressableWithHover} from '../util/PressableWithHover'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {isWeb} from 'platform/detection'
|
|
||||||
import {DraggableScrollView} from './DraggableScrollView'
|
import {DraggableScrollView} from './DraggableScrollView'
|
||||||
|
|
||||||
export interface TabBarProps {
|
export interface TabBarProps {
|
||||||
|
@ -32,13 +31,15 @@ export function TabBar({
|
||||||
[indicatorColor, pal],
|
[indicatorColor, pal],
|
||||||
)
|
)
|
||||||
const {isDesktop, isTablet} = useWebMediaQueries()
|
const {isDesktop, isTablet} = useWebMediaQueries()
|
||||||
|
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
|
||||||
|
|
||||||
// scrolls to the selected item when the page changes
|
// scrolls to the selected item when the page changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollElRef.current?.scrollTo({
|
scrollElRef.current?.scrollTo({
|
||||||
x: itemXs[selectedPage] || 0,
|
x:
|
||||||
|
(itemXs[selectedPage] || 0) - styles.contentContainer.paddingHorizontal,
|
||||||
})
|
})
|
||||||
}, [scrollElRef, itemXs, selectedPage])
|
}, [scrollElRef, itemXs, selectedPage, styles])
|
||||||
|
|
||||||
const onPressItem = useCallback(
|
const onPressItem = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
|
@ -63,8 +64,6 @@ export function TabBar({
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View testID={testID} style={[pal.view, styles.outer]}>
|
<View testID={testID} style={[pal.view, styles.outer]}>
|
||||||
<DraggableScrollView
|
<DraggableScrollView
|
||||||
|
@ -80,15 +79,17 @@ export function TabBar({
|
||||||
testID={`${testID}-selector-${i}`}
|
testID={`${testID}-selector-${i}`}
|
||||||
key={`${item}-${i}`}
|
key={`${item}-${i}`}
|
||||||
onLayout={e => onItemLayout(e, i)}
|
onLayout={e => onItemLayout(e, i)}
|
||||||
style={[styles.item, selected && indicatorStyle]}
|
style={styles.item}
|
||||||
hoverStyle={pal.viewLight}
|
hoverStyle={pal.viewLight}
|
||||||
onPress={() => onPressItem(i)}>
|
onPress={() => onPressItem(i)}>
|
||||||
<Text
|
<View style={[styles.itemInner, selected && indicatorStyle]}>
|
||||||
type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'}
|
<Text
|
||||||
testID={testID ? `${testID}-${item}` : undefined}
|
type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'}
|
||||||
style={selected ? pal.text : pal.textLight}>
|
testID={testID ? `${testID}-${item}` : undefined}
|
||||||
{item}
|
style={selected ? pal.text : pal.textLight}>
|
||||||
</Text>
|
{item}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</PressableWithHover>
|
</PressableWithHover>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
@ -103,18 +104,18 @@ const desktopStyles = StyleSheet.create({
|
||||||
width: 598,
|
width: 598,
|
||||||
},
|
},
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
columnGap: 8,
|
paddingHorizontal: 0,
|
||||||
marginLeft: 14,
|
|
||||||
paddingRight: 14,
|
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
item: {
|
item: {
|
||||||
paddingTop: 14,
|
paddingTop: 14,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
itemInner: {
|
||||||
paddingBottom: 12,
|
paddingBottom: 12,
|
||||||
paddingHorizontal: 10,
|
|
||||||
borderBottomWidth: 3,
|
borderBottomWidth: 3,
|
||||||
borderBottomColor: 'transparent',
|
borderBottomColor: 'transparent',
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -123,17 +124,17 @@ const mobileStyles = StyleSheet.create({
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
},
|
},
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
columnGap: isWeb ? 0 : 20,
|
|
||||||
marginLeft: isWeb ? 0 : 18,
|
|
||||||
paddingRight: isWeb ? 0 : 36,
|
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
|
paddingHorizontal: 8,
|
||||||
},
|
},
|
||||||
item: {
|
item: {
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
paddingBottom: 10,
|
paddingHorizontal: 10,
|
||||||
paddingHorizontal: isWeb ? 8 : 0,
|
|
||||||
borderBottomWidth: 3,
|
|
||||||
borderBottomColor: 'transparent',
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
itemInner: {
|
||||||
|
paddingBottom: 10,
|
||||||
|
borderBottomWidth: 3,
|
||||||
|
borderBottomColor: 'transparent',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -437,6 +437,7 @@ function PostThreadLoaded({
|
||||||
// @ts-ignore our .web version only -prf
|
// @ts-ignore our .web version only -prf
|
||||||
desktopFixedHeight
|
desktopFixedHeight
|
||||||
removeClippedSubviews={isAndroid ? false : undefined}
|
removeClippedSubviews={isAndroid ? false : undefined}
|
||||||
|
windowSize={11}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -327,9 +327,11 @@ let PostThreadItemLoaded = ({
|
||||||
styles.postTextLargeContainer,
|
styles.postTextLargeContainer,
|
||||||
]}>
|
]}>
|
||||||
<RichText
|
<RichText
|
||||||
|
enableTags
|
||||||
|
selectable
|
||||||
value={richText}
|
value={richText}
|
||||||
style={[a.flex_1, a.text_xl]}
|
style={[a.flex_1, a.text_xl]}
|
||||||
selectable
|
authorHandle={post.author.handle}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
@ -521,9 +523,11 @@ let PostThreadItemLoaded = ({
|
||||||
{richText?.text ? (
|
{richText?.text ? (
|
||||||
<View style={styles.postTextContainer}>
|
<View style={styles.postTextContainer}>
|
||||||
<RichText
|
<RichText
|
||||||
|
enableTags
|
||||||
value={richText}
|
value={richText}
|
||||||
style={[a.flex_1, a.text_md]}
|
style={[a.flex_1, a.text_md]}
|
||||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||||
|
authorHandle={post.author.handle}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
|
@ -184,10 +184,12 @@ function PostInner({
|
||||||
{richText.text ? (
|
{richText.text ? (
|
||||||
<View style={styles.postTextContainer}>
|
<View style={styles.postTextContainer}>
|
||||||
<RichText
|
<RichText
|
||||||
|
enableTags
|
||||||
testID="postText"
|
testID="postText"
|
||||||
value={richText}
|
value={richText}
|
||||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||||
style={[a.flex_1, a.text_md]}
|
style={[a.flex_1, a.text_md]}
|
||||||
|
authorHandle={post.author.handle}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
|
@ -347,10 +347,12 @@ let PostContent = ({
|
||||||
{richText.text ? (
|
{richText.text ? (
|
||||||
<View style={styles.postTextContainer}>
|
<View style={styles.postTextContainer}>
|
||||||
<RichText
|
<RichText
|
||||||
|
enableTags
|
||||||
testID="postText"
|
testID="postText"
|
||||||
value={richText}
|
value={richText}
|
||||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||||
style={[a.flex_1, a.text_md]}
|
style={[a.flex_1, a.text_md]}
|
||||||
|
authorHandle={postAuthor.handle}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
|
@ -172,7 +172,7 @@ function ListImpl<ItemT>(
|
||||||
<View
|
<View
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
style={[
|
style={[
|
||||||
styles.contentContainer,
|
!isMobile && styles.sideBorders,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
desktopFixedHeight ? styles.minHeightViewport : null,
|
desktopFixedHeight ? styles.minHeightViewport : null,
|
||||||
pal.border,
|
pal.border,
|
||||||
|
@ -304,7 +304,7 @@ export const List = memo(React.forwardRef(ListImpl)) as <ItemT>(
|
||||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
contentContainer: {
|
sideBorders: {
|
||||||
borderLeftWidth: 1,
|
borderLeftWidth: 1,
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 1,
|
||||||
},
|
},
|
||||||
|
|
|
@ -21,6 +21,7 @@ export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
|
className="nativeDropdown-item"
|
||||||
{...props}
|
{...props}
|
||||||
style={StyleSheet.flatten([
|
style={StyleSheet.flatten([
|
||||||
styles.item,
|
styles.item,
|
||||||
|
@ -232,6 +233,10 @@ const styles = StyleSheet.create({
|
||||||
paddingLeft: 12,
|
paddingLeft: 12,
|
||||||
paddingRight: 12,
|
paddingRight: 12,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||||
|
outline: 0,
|
||||||
|
border: 0,
|
||||||
},
|
},
|
||||||
itemTitle: {
|
itemTitle: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
|
|
@ -34,6 +34,7 @@ import {useLingui} from '@lingui/react'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||||
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
|
|
||||||
let PostDropdownBtn = ({
|
let PostDropdownBtn = ({
|
||||||
testID,
|
testID,
|
||||||
|
@ -67,6 +68,7 @@ let PostDropdownBtn = ({
|
||||||
const {hidePost} = useHiddenPostsApi()
|
const {hidePost} = useHiddenPostsApi()
|
||||||
const openLink = useOpenLink()
|
const openLink = useOpenLink()
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
|
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||||
|
|
||||||
const rootUri = record.reply?.root?.uri || postUri
|
const rootUri = record.reply?.root?.uri || postUri
|
||||||
const isThreadMuted = mutedThreads.includes(rootUri)
|
const isThreadMuted = mutedThreads.includes(rootUri)
|
||||||
|
@ -210,6 +212,20 @@ let PostDropdownBtn = ({
|
||||||
web: 'comment-slash',
|
web: 'comment-slash',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
hasSession && {
|
||||||
|
label: _(msg`Mute words & tags`),
|
||||||
|
onPress() {
|
||||||
|
mutedWordsDialogControl.open()
|
||||||
|
},
|
||||||
|
testID: 'postDropdownMuteWordsBtn',
|
||||||
|
icon: {
|
||||||
|
ios: {
|
||||||
|
name: 'speaker.slash',
|
||||||
|
},
|
||||||
|
android: 'ic_lock_silent_mode',
|
||||||
|
web: 'filter',
|
||||||
|
},
|
||||||
|
},
|
||||||
hasSession &&
|
hasSession &&
|
||||||
!isAuthor &&
|
!isAuthor &&
|
||||||
!isPostHidden && {
|
!isPostHidden && {
|
||||||
|
|
|
@ -128,10 +128,12 @@ export function QuoteEmbed({
|
||||||
) : null}
|
) : null}
|
||||||
{richText ? (
|
{richText ? (
|
||||||
<RichText
|
<RichText
|
||||||
|
enableTags
|
||||||
value={richText}
|
value={richText}
|
||||||
style={[a.text_md]}
|
style={[a.text_md]}
|
||||||
numberOfLines={20}
|
numberOfLines={20}
|
||||||
disableLinks
|
disableLinks
|
||||||
|
authorHandle={quote.author.handle}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{embed && <PostEmbeds embed={embed} moderation={{}} />}
|
{embed && <PostEmbeds embed={embed} moderation={{}} />}
|
||||||
|
|
|
@ -7,6 +7,9 @@ import {lh} from 'lib/styles'
|
||||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||||
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
|
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {makeTagLink} from 'lib/routes/links'
|
||||||
|
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
|
||||||
const WORD_WRAP = {wordWrap: 1}
|
const WORD_WRAP = {wordWrap: 1}
|
||||||
|
|
||||||
|
@ -82,6 +85,7 @@ export function RichText({
|
||||||
for (const segment of richText.segments()) {
|
for (const segment of richText.segments()) {
|
||||||
const link = segment.link
|
const link = segment.link
|
||||||
const mention = segment.mention
|
const mention = segment.mention
|
||||||
|
const tag = segment.tag
|
||||||
if (
|
if (
|
||||||
!noLinks &&
|
!noLinks &&
|
||||||
mention &&
|
mention &&
|
||||||
|
@ -115,6 +119,21 @@ export function RichText({
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else if (
|
||||||
|
!noLinks &&
|
||||||
|
tag &&
|
||||||
|
AppBskyRichtextFacet.validateTag(tag).success
|
||||||
|
) {
|
||||||
|
els.push(
|
||||||
|
<RichTextTag
|
||||||
|
key={key}
|
||||||
|
text={segment.text}
|
||||||
|
type={type}
|
||||||
|
style={style}
|
||||||
|
lineHeightStyle={lineHeightStyle}
|
||||||
|
selectable={selectable}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
els.push(segment.text)
|
els.push(segment.text)
|
||||||
}
|
}
|
||||||
|
@ -133,3 +152,50 @@ export function RichText({
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RichTextTag({
|
||||||
|
text: tag,
|
||||||
|
type,
|
||||||
|
style,
|
||||||
|
lineHeightStyle,
|
||||||
|
selectable,
|
||||||
|
}: {
|
||||||
|
text: string
|
||||||
|
type?: TypographyVariant
|
||||||
|
style?: StyleProp<TextStyle>
|
||||||
|
lineHeightStyle?: TextStyle
|
||||||
|
selectable?: boolean
|
||||||
|
}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const control = useTagMenuControl()
|
||||||
|
|
||||||
|
const open = React.useCallback(() => {
|
||||||
|
control.open()
|
||||||
|
}, [control])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<TagMenu control={control} tag={tag}>
|
||||||
|
{isNative ? (
|
||||||
|
<TextLink
|
||||||
|
type={type}
|
||||||
|
text={tag}
|
||||||
|
// segment.text has the leading "#" while tag.tag does not
|
||||||
|
href={makeTagLink(tag)}
|
||||||
|
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
|
||||||
|
dataSet={WORD_WRAP}
|
||||||
|
selectable={selectable}
|
||||||
|
onPress={open}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
selectable={selectable}
|
||||||
|
type={type}
|
||||||
|
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}>
|
||||||
|
{tag}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TagMenu>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -103,6 +103,7 @@ import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
|
||||||
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
|
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
|
||||||
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
|
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
|
||||||
import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown'
|
import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown'
|
||||||
|
import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter'
|
||||||
|
|
||||||
library.add(
|
library.add(
|
||||||
faAddressCard,
|
faAddressCard,
|
||||||
|
@ -208,4 +209,5 @@ library.add(
|
||||||
faX,
|
faX,
|
||||||
faXmark,
|
faXmark,
|
||||||
faChevronDown,
|
faChevronDown,
|
||||||
|
faFilter,
|
||||||
)
|
)
|
||||||
|
|
|
@ -31,6 +31,7 @@ import {
|
||||||
useProfileUpdateMutation,
|
useProfileUpdateMutation,
|
||||||
} from '#/state/queries/profile'
|
} from '#/state/queries/profile'
|
||||||
import {ScrollView} from '../com/util/Views'
|
import {ScrollView} from '../com/util/Views'
|
||||||
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
|
||||||
export function ModerationScreen({}: Props) {
|
export function ModerationScreen({}: Props) {
|
||||||
|
@ -40,6 +41,7 @@ export function ModerationScreen({}: Props) {
|
||||||
const {screen, track} = useAnalytics()
|
const {screen, track} = useAnalytics()
|
||||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
const {openModal} = useModalControls()
|
const {openModal} = useModalControls()
|
||||||
|
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
|
@ -71,7 +73,7 @@ export function ModerationScreen({}: Props) {
|
||||||
accessibilityRole="tab"
|
accessibilityRole="tab"
|
||||||
accessibilityLabel={_(msg`Content filtering`)}
|
accessibilityLabel={_(msg`Content filtering`)}
|
||||||
accessibilityHint={_(
|
accessibilityHint={_(
|
||||||
msg`Opens modal for content filtering preferences`,
|
msg`Opens modal for content filtering settings`,
|
||||||
)}>
|
)}>
|
||||||
<View style={[styles.iconContainer, pal.btn]}>
|
<View style={[styles.iconContainer, pal.btn]}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
|
@ -83,6 +85,23 @@ export function ModerationScreen({}: Props) {
|
||||||
<Trans>Content filtering</Trans>
|
<Trans>Content filtering</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="mutedWordsBtn"
|
||||||
|
style={[styles.linkCard, pal.view]}
|
||||||
|
onPress={() => mutedWordsDialogControl.open()}
|
||||||
|
accessibilityRole="tab"
|
||||||
|
accessibilityLabel={_(msg`Muted words & tags`)}
|
||||||
|
accessibilityHint={_(msg`Open modal for muted words settings`)}>
|
||||||
|
<View style={[styles.iconContainer, pal.btn]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="filter"
|
||||||
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
<Trans>Muted words & tags</Trans>
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
<Link
|
<Link
|
||||||
testID="moderationlistsBtn"
|
testID="moderationlistsBtn"
|
||||||
style={[styles.linkCard, pal.view]}
|
style={[styles.linkCard, pal.view]}
|
||||||
|
|
|
@ -16,7 +16,7 @@ import {
|
||||||
FontAwesomeIcon,
|
FontAwesomeIcon,
|
||||||
FontAwesomeIconStyle,
|
FontAwesomeIconStyle,
|
||||||
} from '@fortawesome/react-native-fontawesome'
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {
|
import {
|
||||||
|
@ -53,6 +53,7 @@ import {listenSoftReset} from '#/state/events'
|
||||||
import {s} from '#/lib/styles'
|
import {s} from '#/lib/styles'
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
import {augmentSearchQuery} from '#/lib/strings/helpers'
|
import {augmentSearchQuery} from '#/lib/strings/helpers'
|
||||||
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
|
||||||
function Loader() {
|
function Loader() {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
@ -448,6 +449,7 @@ export function SearchScreenInner({
|
||||||
export function SearchScreen(
|
export function SearchScreen(
|
||||||
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
|
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
|
||||||
) {
|
) {
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const textInput = React.useRef<TextInput>(null)
|
const textInput = React.useRef<TextInput>(null)
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
@ -472,6 +474,27 @@ export function SearchScreen(
|
||||||
React.useState(false)
|
React.useState(false)
|
||||||
const [searchHistory, setSearchHistory] = React.useState<string[]>([])
|
const [searchHistory, setSearchHistory] = React.useState<string[]>([])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Search screen's `q` param
|
||||||
|
*/
|
||||||
|
const queryParam = props.route?.params?.q
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If `true`, this means we received new instructions from the router. This
|
||||||
|
* is handled in a effect, and used to update the value of `query` locally
|
||||||
|
* within this screen.
|
||||||
|
*/
|
||||||
|
const routeParamsMismatch = queryParam && queryParam !== query
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (queryParam && routeParamsMismatch) {
|
||||||
|
// reset immediately and let local state take over
|
||||||
|
navigation.setParams({q: ''})
|
||||||
|
// update query for next search
|
||||||
|
setQuery(queryParam)
|
||||||
|
}
|
||||||
|
}, [queryParam, routeParamsMismatch, navigation])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const loadSearchHistory = async () => {
|
const loadSearchHistory = async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -774,6 +797,8 @@ export function SearchScreen(
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
|
) : routeParamsMismatch ? (
|
||||||
|
<ActivityIndicator />
|
||||||
) : (
|
) : (
|
||||||
<SearchScreenInner query={query} />
|
<SearchScreenInner query={query} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -29,6 +29,7 @@ import {useSession} from '#/state/session'
|
||||||
import {useCloseAnyActiveElement} from '#/state/util'
|
import {useCloseAnyActiveElement} from '#/state/util'
|
||||||
import * as notifications from 'lib/notifications/notifications'
|
import * as notifications from 'lib/notifications/notifications'
|
||||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||||
|
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||||
|
|
||||||
function ShellInner() {
|
function ShellInner() {
|
||||||
const isDrawerOpen = useIsDrawerOpen()
|
const isDrawerOpen = useIsDrawerOpen()
|
||||||
|
@ -94,6 +95,7 @@ function ShellInner() {
|
||||||
</View>
|
</View>
|
||||||
<Composer winHeight={winDim.height} />
|
<Composer winHeight={winDim.height} />
|
||||||
<ModalsContainer />
|
<ModalsContainer />
|
||||||
|
<MutedWordsDialog />
|
||||||
<PortalOutlet />
|
<PortalOutlet />
|
||||||
<Lightbox />
|
<Lightbox />
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
||||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||||
|
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||||
|
|
||||||
function ShellInner() {
|
function ShellInner() {
|
||||||
const isDrawerOpen = useIsDrawerOpen()
|
const isDrawerOpen = useIsDrawerOpen()
|
||||||
|
@ -40,6 +41,7 @@ function ShellInner() {
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<Composer winHeight={0} />
|
<Composer winHeight={0} />
|
||||||
<ModalsContainer />
|
<ModalsContainer />
|
||||||
|
<MutedWordsDialog />
|
||||||
<PortalOutlet />
|
<PortalOutlet />
|
||||||
<Lightbox />
|
<Lightbox />
|
||||||
{!isDesktop && isDrawerOpen && (
|
{!isDesktop && isDrawerOpen && (
|
||||||
|
|
|
@ -209,6 +209,11 @@
|
||||||
[data-tooltip]:hover::before {
|
[data-tooltip]:hover::before {
|
||||||
display:block;
|
display:block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* NativeDropdown component */
|
||||||
|
.nativeDropdown-item:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
14
yarn.lock
14
yarn.lock
|
@ -34,6 +34,20 @@
|
||||||
jsonpointer "^5.0.0"
|
jsonpointer "^5.0.0"
|
||||||
leven "^3.1.0"
|
leven "^3.1.0"
|
||||||
|
|
||||||
|
"@atproto/api@^0.10.0":
|
||||||
|
version "0.10.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.0.tgz#ca34dfa8f9b1e6ba021094c40cb0ff3c4c254044"
|
||||||
|
integrity sha512-TSVCHh3UUZLtNzh141JwLicfYTc7TvVFvQJSWeOZLHr3Sk+9hqEY+9Itaqp1DAW92r4i25ChaMc/50sg4etAWQ==
|
||||||
|
dependencies:
|
||||||
|
"@atproto/common-web" "^0.2.3"
|
||||||
|
"@atproto/lexicon" "^0.3.1"
|
||||||
|
"@atproto/syntax" "^0.1.5"
|
||||||
|
"@atproto/xrpc" "^0.4.1"
|
||||||
|
multiformats "^9.9.0"
|
||||||
|
tlds "^1.234.0"
|
||||||
|
typed-emitter "^2.1.0"
|
||||||
|
zod "^3.21.4"
|
||||||
|
|
||||||
"@atproto/api@^0.9.5":
|
"@atproto/api@^0.9.5":
|
||||||
version "0.9.5"
|
version "0.9.5"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.5.tgz#630e5d9520bba38d0cd348c8028ddbb73bd074f8"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.5.tgz#630e5d9520bba38d0cd348c8028ddbb73bd074f8"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue