Merge branch 'main' into 3p-moderators
This commit is contained in:
@@ -0,0 +1,692 @@
|
||||
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({
|
||||
mutedWords: [{value: 'outlineTag', targets: ['tag']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: ['outlineTag'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: inline tag`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post #inlineTag`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: ['outlineTag'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
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({
|
||||
mutedWords: [{value: 'inlineTag', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: ['outlineTag'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: only tag targets`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
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({
|
||||
mutedWords: [{value: '希', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: long muted word, short post`, () => {
|
||||
const rt = new RichText({
|
||||
text: `hey`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'politics', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`match: exact text`, () => {
|
||||
const rt = new RichText({
|
||||
text: `javascript`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'javascript', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
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({
|
||||
mutedWords: [{value: 'javascript', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: partial word`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Use your brain, Eric`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'ai', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`match: multiline`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Use your\n\tbrain, Eric`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'brain', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: :)`, () => {
|
||||
const rt = new RichText({
|
||||
text: `So happy :)`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `:)`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
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({
|
||||
mutedWords: [{value: 'yay!', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: yay`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'yay', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
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({
|
||||
mutedWords: [{value: 'y!ppee', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
// single exclamation point, source has double
|
||||
it(`no match: y!ppee!`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'y!ppee!', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
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({
|
||||
mutedWords: [{value: 'S@assy', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: s@assy`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 's@assy', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
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({
|
||||
mutedWords: [{value: 'new york times', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`!command`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Idk maybe a bot !command`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: !command`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `!command`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: command`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `command`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: !command`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Idk maybe a bot command`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `!command`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
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({
|
||||
mutedWords: [{value: `e/acc`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: acc`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `acc`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
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({
|
||||
mutedWords: [{value: `super-bad`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: super`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `super`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: super bad`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `super bad`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: superbad`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `superbad`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
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({
|
||||
mutedWords: [{value: `idk what this would be`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: idk what this would be for`, () => {
|
||||
// extra word
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [
|
||||
{value: `idk what this would be for`, targets: ['content']},
|
||||
],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`match: idk`, () => {
|
||||
// extra word
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `idk`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: idkwhatthiswouldbe`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`parentheses`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Post with context(iykyk)`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: context(iykyk)`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `context(iykyk)`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: context`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `context`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: iykyk`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `iykyk`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: (iykyk)`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `(iykyk)`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`🦋`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Post with 🦋`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: 🦋`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `🦋`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
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({
|
||||
mutedWords: [{value: 'stop worrying', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: turtles, or how`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'turtles, or how', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(`languages without spaces`, () => {
|
||||
// I love turtles, or how I learned to stop worrying and love the internet
|
||||
describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => {
|
||||
const rt = new RichText({
|
||||
text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
// internet
|
||||
it(`match: インターネット`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'インターネット', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
languages: ['ja'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(`doesn't mute own post`, () => {
|
||||
it(`does mute if it isn't own post`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Mute words!`,
|
||||
})
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'words', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`doesn't mute own post when muted word is in text`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Mute words!`,
|
||||
})
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'words', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: true,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`doesn't mute own post when muted word is in tags`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Mute #words!`,
|
||||
})
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'words', targets: ['tags']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: true,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -104,18 +104,18 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
|
||||
// add image embed if present
|
||||
if (opts.images?.length) {
|
||||
logger.info(`Uploading images`, {
|
||||
logger.debug(`Uploading images`, {
|
||||
count: opts.images.length,
|
||||
})
|
||||
|
||||
const images: AppBskyEmbedImages.Image[] = []
|
||||
for (const image of opts.images) {
|
||||
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
||||
logger.info(`Compressing image`)
|
||||
logger.debug(`Compressing image`)
|
||||
await image.compress()
|
||||
const path = image.compressed?.path ?? image.path
|
||||
const {width, height} = image.compressed || image
|
||||
logger.info(`Uploading image`)
|
||||
logger.debug(`Uploading image`)
|
||||
const res = await uploadBlob(agent, path, 'image/jpeg')
|
||||
images.push({
|
||||
image: res.data.blob,
|
||||
|
||||
@@ -75,3 +75,9 @@ export const HITSLOP_20 = createHitslop(20)
|
||||
export const HITSLOP_30 = createHitslop(30)
|
||||
export const BACK_HITSLOP = HITSLOP_30
|
||||
export const MAX_POST_LINES = 25
|
||||
|
||||
export const BSKY_FEED_OWNER_DIDS = [
|
||||
'did:plc:z72i7hdynmk6r22z27h6tvur',
|
||||
'did:plc:vpkhqolt662uhesyj6nxm7ys',
|
||||
'did:plc:q6gjnaw2blty4crticxkmujt',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import {Dimensions} from 'react-native'
|
||||
|
||||
const MIN_POST_HEIGHT = 100
|
||||
|
||||
export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) {
|
||||
return React.useMemo(() => {
|
||||
const screenHeight = Dimensions.get('window').height
|
||||
return Math.ceil(screenHeight / minItemHeight) + 1
|
||||
}, [minItemHeight])
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import * as Linking from 'expo-linking'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useComposerControls} from 'state/shell'
|
||||
import {useSession} from 'state/session'
|
||||
import {useCloseAllActiveElements} from 'state/util'
|
||||
|
||||
type IntentType = 'compose'
|
||||
|
||||
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
|
||||
|
||||
export function useIntentHandler() {
|
||||
const incomingUrl = Linking.useURL()
|
||||
const composeIntent = useComposeIntent()
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleIncomingURL = (url: string) => {
|
||||
// We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three
|
||||
// slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care
|
||||
// of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first
|
||||
// path parameter is in pathname rather than in hostname.
|
||||
if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) {
|
||||
url = url.replace('bluesky://', 'bluesky:///')
|
||||
}
|
||||
|
||||
const urlp = new URL(url)
|
||||
const [_, intent, intentType] = urlp.pathname.split('/')
|
||||
|
||||
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
|
||||
// intent check. On web, we have to check the first part of the path since we have an actual hostname
|
||||
const isIntent = intent === 'intent'
|
||||
const params = urlp.searchParams
|
||||
|
||||
if (!isIntent) return
|
||||
|
||||
switch (intentType as IntentType) {
|
||||
case 'compose': {
|
||||
composeIntent({
|
||||
text: params.get('text'),
|
||||
imageUrisStr: params.get('imageUris'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (incomingUrl) handleIncomingURL(incomingUrl)
|
||||
}, [incomingUrl, composeIntent])
|
||||
}
|
||||
|
||||
function useComposeIntent() {
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const {openComposer} = useComposerControls()
|
||||
const {hasSession} = useSession()
|
||||
|
||||
return React.useCallback(
|
||||
({
|
||||
text,
|
||||
imageUrisStr,
|
||||
}: {
|
||||
text: string | null
|
||||
imageUrisStr: string | null // unused for right now, will be used later with intents
|
||||
}) => {
|
||||
if (!hasSession) return
|
||||
|
||||
closeAllActiveElements()
|
||||
|
||||
const imageUris = imageUrisStr
|
||||
?.split(',')
|
||||
.filter(part => {
|
||||
// For some security, we're going to filter out any image uri that is external. We don't want someone to
|
||||
// be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
|
||||
// and we load that image
|
||||
if (part.includes('https://') || part.includes('http://')) {
|
||||
return false
|
||||
}
|
||||
// We also should just filter out cases that don't have all the info we need
|
||||
return VALID_IMAGE_REGEX.test(part)
|
||||
})
|
||||
.map(part => {
|
||||
const [uri, width, height] = part.split('|')
|
||||
return {uri, width: Number(width), height: Number(height)}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
openComposer({
|
||||
text: text ?? undefined,
|
||||
imageUris: isNative ? imageUris : undefined,
|
||||
})
|
||||
}, 500)
|
||||
},
|
||||
[hasSession, closeAllActiveElements, openComposer],
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,144 @@
|
||||
import {moderatePost} from '@atproto/api'
|
||||
import {
|
||||
// AppBskyEmbedRecord,
|
||||
// AppBskyEmbedRecordWithMedia,
|
||||
moderatePost,
|
||||
AppBskyActorDefs,
|
||||
// AppBskyFeedPost,
|
||||
AppBskyRichtextFacet,
|
||||
// AppBskyEmbedImages,
|
||||
// AppBskyEmbedExternal,
|
||||
} from '@atproto/api'
|
||||
|
||||
type ModeratePost = typeof moderatePost
|
||||
type Options = Parameters<ModeratePost>[1] & {
|
||||
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,
|
||||
}
|
||||
|
||||
/**
|
||||
* List of 2-letter lang codes for languages that either don't use spaces, or
|
||||
* don't use spaces in a way conducive to word-based filtering.
|
||||
*
|
||||
* For these, we use a simple `String.includes` to check for a match.
|
||||
*/
|
||||
const LANGUAGE_EXCEPTIONS = [
|
||||
'ja', // Japanese
|
||||
'zh', // Chinese
|
||||
'ko', // Korean
|
||||
'th', // Thai
|
||||
'vi', // Vietnamese
|
||||
]
|
||||
|
||||
export function hasMutedWord({
|
||||
mutedWords,
|
||||
text,
|
||||
facets,
|
||||
outlineTags,
|
||||
languages,
|
||||
isOwnPost,
|
||||
}: {
|
||||
mutedWords: AppBskyActorDefs.MutedWord[]
|
||||
text: string
|
||||
facets?: AppBskyRichtextFacet.Main[]
|
||||
outlineTags?: string[]
|
||||
languages?: string[]
|
||||
isOwnPost: boolean
|
||||
}) {
|
||||
if (isOwnPost) return false
|
||||
|
||||
const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '')
|
||||
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 or other exception, has to use includes
|
||||
if ((mutedWord.length === 1 || exception) && 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(
|
||||
@@ -12,4 +148,238 @@ export function moderatePost_wrapped(
|
||||
const moderation = moderatePost(subject, opts)
|
||||
moderation.addHidden(opts.hiddenPosts?.includes(subject.uri) || false)
|
||||
return moderation
|
||||
/* TODO
|
||||
const {hiddenPosts = [], mutedWords = [], ...options} = opts
|
||||
const moderations = moderatePost(subject, options)
|
||||
const isOwnPost = subject.author.did === opts.userDid
|
||||
|
||||
if (hiddenPosts.includes(subject.uri)) {
|
||||
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: 'post-hidden',
|
||||
source: {type: 'user'},
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyFeedPost.isRecord(subject.record)) {
|
||||
let muted = hasMutedWord({
|
||||
mutedWords,
|
||||
text: subject.record.text,
|
||||
facets: subject.record.facets || [],
|
||||
outlineTags: subject.record.tags || [],
|
||||
languages: subject.record.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
|
||||
if (
|
||||
subject.record.embed &&
|
||||
AppBskyEmbedImages.isMain(subject.record.embed)
|
||||
) {
|
||||
for (const image of subject.record.embed.images) {
|
||||
muted =
|
||||
muted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: subject.record.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
let embedHidden = false
|
||||
let embedMuted = false
|
||||
let externalMuted = false
|
||||
|
||||
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
|
||||
embedHidden = hiddenPosts.includes(subject.embed.record.uri)
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
|
||||
) {
|
||||
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
|
||||
}
|
||||
|
||||
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
|
||||
if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
|
||||
const embeddedPost = subject.embed.record.value
|
||||
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: embeddedPost.text,
|
||||
facets: embeddedPost.facets,
|
||||
outlineTags: embeddedPost.tags,
|
||||
languages: embeddedPost.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
|
||||
if (AppBskyEmbedImages.isMain(embeddedPost.embed)) {
|
||||
for (const image of embeddedPost.embed.images) {
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: embeddedPost.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) {
|
||||
const {external} = embeddedPost.embed
|
||||
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: external.title + ' ' + external.description,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
|
||||
if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) {
|
||||
if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) {
|
||||
const {external} = embeddedPost.embed.media
|
||||
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: external.title + ' ' + external.description,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
|
||||
if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) {
|
||||
for (const image of embeddedPost.embed.media.images) {
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: AppBskyFeedPost.isRecord(embeddedPost.record)
|
||||
? embeddedPost.langs
|
||||
: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyEmbedExternal.isView(subject.embed)) {
|
||||
const {external} = subject.embed
|
||||
|
||||
externalMuted =
|
||||
externalMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: external.title + ' ' + external.description,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
|
||||
) {
|
||||
if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) {
|
||||
const post = subject.embed.record.record.value
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: post.text,
|
||||
facets: post.facets,
|
||||
outlineTags: post.tags,
|
||||
languages: post.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
|
||||
if (AppBskyEmbedImages.isView(subject.embed.media)) {
|
||||
for (const image of subject.embed.media.images) {
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: AppBskyFeedPost.isRecord(subject.record)
|
||||
? subject.record.langs
|
||||
: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (embedHidden) {
|
||||
moderations.embed.filter = true
|
||||
moderations.embed.blur = true
|
||||
if (!moderations.embed.cause) {
|
||||
moderations.embed.cause = {
|
||||
// @ts-ignore Temporary extension to the moderation system -prf
|
||||
type: 'post-hidden',
|
||||
source: {type: 'user'},
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
} else if (externalMuted || embedMuted) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return moderations
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -84,6 +84,16 @@ export function useModerationCauseDescription(
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO
|
||||
// if (cause.type === 'muted-word') {
|
||||
// return {
|
||||
// icon: EyeSlash,
|
||||
// name: _(msg`Post hidden by muted word`),
|
||||
// description: _(
|
||||
// msg`You've chosen to hide a word or tag within this post.`,
|
||||
// ),
|
||||
// }
|
||||
// }
|
||||
// @ts-ignore Temporary extension to the moderation system -prf
|
||||
if (cause.type === 'hidden') {
|
||||
return {
|
||||
|
||||
@@ -25,3 +25,13 @@ export function makeCustomFeedLink(
|
||||
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
|
||||
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}` : ''),
|
||||
)}`
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export type CommonNavigatorParams = {
|
||||
PreferencesFollowingFeed: undefined
|
||||
PreferencesThreads: undefined
|
||||
PreferencesExternalEmbeds: undefined
|
||||
Search: {q?: string}
|
||||
Hashtag: {tag: string; author?: string}
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -69,6 +71,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
Search: {q?: string}
|
||||
Feeds: undefined
|
||||
Notifications: undefined
|
||||
Hashtag: {tag: string; author?: string}
|
||||
}
|
||||
|
||||
export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -81,6 +84,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
NotificationsTab: undefined
|
||||
Notifications: undefined
|
||||
MyProfileTab: undefined
|
||||
Hashtag: {tag: string; author?: string}
|
||||
}
|
||||
|
||||
// NOTE
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Statsig,
|
||||
StatsigProvider,
|
||||
useGate as useStatsigGate,
|
||||
} from 'statsig-react-native-expo'
|
||||
import {useSession} from '../../state/session'
|
||||
import {sha256} from 'js-sha256'
|
||||
|
||||
const statsigOptions = {
|
||||
environment: {
|
||||
tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
|
||||
},
|
||||
// Don't block on waiting for network. The fetched config will kick in on next load.
|
||||
// This ensures the UI is always consistent and doesn't update mid-session.
|
||||
// Note this makes cold load (no local storage) and private mode return `false` for all gates.
|
||||
initTimeoutMs: 1,
|
||||
}
|
||||
|
||||
export function logEvent(
|
||||
eventName: string,
|
||||
value?: string | number | null,
|
||||
metadata?: Record<string, string> | null,
|
||||
) {
|
||||
Statsig.logEvent(eventName, value, metadata)
|
||||
}
|
||||
|
||||
export function useGate(gateName: string) {
|
||||
const {isLoading, value} = useStatsigGate(gateName)
|
||||
if (isLoading) {
|
||||
// This should not happen because of waitForInitialization={true}.
|
||||
console.error('Did not expected isLoading to ever be true.')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function toStatsigUser(did: string | undefined) {
|
||||
let userID: string | undefined
|
||||
if (did) {
|
||||
userID = sha256(did)
|
||||
}
|
||||
return {userID}
|
||||
}
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const {currentAccount} = useSession()
|
||||
const currentStatsigUser = React.useMemo(
|
||||
() => toStatsigUser(currentAccount?.did),
|
||||
[currentAccount?.did],
|
||||
)
|
||||
return (
|
||||
<StatsigProvider
|
||||
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
|
||||
mountKey={currentStatsigUser.userID}
|
||||
user={currentStatsigUser}
|
||||
// This isn't really blocking due to short initTimeoutMs above.
|
||||
// However, it ensures `isLoading` is always `false`.
|
||||
waitForInitialization={true}
|
||||
options={statsigOptions}>
|
||||
{children}
|
||||
</StatsigProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Statsig,
|
||||
StatsigProvider,
|
||||
useGate as useStatsigGate,
|
||||
} from 'statsig-react'
|
||||
import {useSession} from '../../state/session'
|
||||
import {sha256} from 'js-sha256'
|
||||
|
||||
const statsigOptions = {
|
||||
environment: {
|
||||
tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
|
||||
},
|
||||
// Don't block on waiting for network. The fetched config will kick in on next load.
|
||||
// This ensures the UI is always consistent and doesn't update mid-session.
|
||||
// Note this makes cold load (no local storage) and private mode return `false` for all gates.
|
||||
initTimeoutMs: 1,
|
||||
}
|
||||
|
||||
export function logEvent(
|
||||
eventName: string,
|
||||
value?: string | number | null,
|
||||
metadata?: Record<string, string> | null,
|
||||
) {
|
||||
Statsig.logEvent(eventName, value, metadata)
|
||||
}
|
||||
|
||||
export function useGate(gateName: string) {
|
||||
const {isLoading, value} = useStatsigGate(gateName)
|
||||
if (isLoading) {
|
||||
// This should not happen because of waitForInitialization={true}.
|
||||
console.error('Did not expected isLoading to ever be true.')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function toStatsigUser(did: string | undefined) {
|
||||
let userID: string | undefined
|
||||
if (did) {
|
||||
userID = sha256(did)
|
||||
}
|
||||
return {userID}
|
||||
}
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const {currentAccount} = useSession()
|
||||
const currentStatsigUser = React.useMemo(
|
||||
() => toStatsigUser(currentAccount?.did),
|
||||
[currentAccount?.did],
|
||||
)
|
||||
return (
|
||||
<StatsigProvider
|
||||
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
|
||||
mountKey={currentStatsigUser.userID}
|
||||
user={currentStatsigUser}
|
||||
// This isn't really blocking due to short initTimeoutMs above.
|
||||
// However, it ensures `isLoading` is always `false`.
|
||||
waitForInitialization={true}
|
||||
options={statsigOptions}>
|
||||
{children}
|
||||
</StatsigProvider>
|
||||
)
|
||||
}
|
||||
@@ -8,10 +8,27 @@ export function pluralize(n: number, base: string, plural?: string): string {
|
||||
return base + 's'
|
||||
}
|
||||
|
||||
export function enforceLen(str: string, len: number, ellipsis = false): string {
|
||||
export function enforceLen(
|
||||
str: string,
|
||||
len: number,
|
||||
ellipsis = false,
|
||||
mode: 'end' | 'middle' = 'end',
|
||||
): string {
|
||||
str = str || ''
|
||||
if (str.length > len) {
|
||||
return str.slice(0, len) + (ellipsis ? '...' : '')
|
||||
if (ellipsis) {
|
||||
if (mode === 'end') {
|
||||
return str.slice(0, len) + '…'
|
||||
} else if (mode === 'middle') {
|
||||
const half = Math.floor(len / 2)
|
||||
return str.slice(0, half) + '…' + str.slice(-half)
|
||||
} else {
|
||||
// fallback
|
||||
return str.slice(0, len)
|
||||
}
|
||||
} else {
|
||||
return str.slice(0, len)
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
@@ -148,6 +148,11 @@ export function feedUriToHref(url: string): string {
|
||||
export function linkRequiresWarning(uri: string, label: string) {
|
||||
const labelDomain = labelToDomain(label)
|
||||
|
||||
// If the uri started with a / we know it is internal.
|
||||
if (uri.startsWith('/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
let urip
|
||||
try {
|
||||
urip = new URL(uri)
|
||||
@@ -156,18 +161,15 @@ export function linkRequiresWarning(uri: string, label: string) {
|
||||
}
|
||||
|
||||
const host = urip.hostname.toLowerCase()
|
||||
|
||||
if (host === 'bsky.app') {
|
||||
// Hosts that end with bsky.app or bsky.social should be trusted by default.
|
||||
if (
|
||||
host.endsWith('bsky.app') ||
|
||||
host.endsWith('bsky.social') ||
|
||||
host.endsWith('blueskyweb.xyz')
|
||||
) {
|
||||
// if this is a link to internal content,
|
||||
// warn if it represents itself as a URL to another app
|
||||
if (
|
||||
labelDomain &&
|
||||
labelDomain !== 'bsky.app' &&
|
||||
isPossiblyAUrl(labelDomain)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
|
||||
} else {
|
||||
// if this is a link to external content,
|
||||
// warn if the label doesnt match the target
|
||||
|
||||
+2
-2
@@ -357,8 +357,8 @@ export const dimTheme: Theme = {
|
||||
textVeryLight: dimPalette.contrast_400,
|
||||
replyLine: dimPalette.contrast_200,
|
||||
replyLineDot: dimPalette.contrast_200,
|
||||
unreadNotifBg: `hsl(211, 48%, 17%)`,
|
||||
unreadNotifBorder: `hsl(211, 48%, 30%)`,
|
||||
unreadNotifBg: dimPalette.primary_975,
|
||||
unreadNotifBorder: dimPalette.primary_900,
|
||||
postCtrl: dimPalette.contrast_500,
|
||||
brandText: dimPalette.primary_500,
|
||||
emptyStateIcon: dimPalette.contrast_300,
|
||||
|
||||
Reference in New Issue
Block a user