Merge remote-tracking branch 'upstream/main' into Improve-notification-localization
This commit is contained in:
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- divy/bskylink
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
name: build-and-push-link-aws
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- divy/bskylink
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
name: build-and-push-ogcard-aws
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- divy/bskycard
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.22'
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
|
||||
- name: Check
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.22'
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
|
||||
- name: Lint
|
||||
|
||||
@@ -115,3 +115,6 @@ src/locale/locales/**/*.js
|
||||
*.apk
|
||||
*.aab
|
||||
*.ipa
|
||||
|
||||
# ogcard assets
|
||||
bskyogcard/src/assets/fonts/noto-*
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.21-bullseye AS build-env
|
||||
FROM golang:1.22-bullseye AS build-env
|
||||
|
||||
WORKDIR /usr/src/social-app
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ RUN yarn install --frozen-lockfile
|
||||
COPY ./bskyogcard ./
|
||||
|
||||
# build then prune dev deps
|
||||
RUN yarn build
|
||||
RUN yarn install-fonts && yarn build
|
||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||
|
||||
# Uses assets from build stage to reduce build size
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.21-bullseye AS build-env
|
||||
FROM golang:1.22-bullseye AS build-env
|
||||
|
||||
WORKDIR /usr/src/social-app
|
||||
|
||||
|
||||
@@ -132,8 +132,7 @@ appId: xyz.blueskyweb.app
|
||||
id: "feedItem-by-bob.test"
|
||||
- tapOn:
|
||||
id: "e2eGotoFeeds"
|
||||
- tapOn:
|
||||
id: "saved-feed-Good Ppl"
|
||||
- tapOn: "Good Ppl"
|
||||
- assertVisible:
|
||||
id: "feedItem-by-bob.test"
|
||||
- tapOn:
|
||||
@@ -144,8 +143,7 @@ appId: xyz.blueskyweb.app
|
||||
id: "homeScreenFeedTabs-Good Ppl"
|
||||
- tapOn:
|
||||
id: "e2eGotoLists"
|
||||
- tapOn:
|
||||
id: "list-Good Ppl"
|
||||
- tapOn: "Good Ppl"
|
||||
|
||||
- tapOn: "About"
|
||||
- assertVisible:
|
||||
@@ -171,8 +169,7 @@ appId: xyz.blueskyweb.app
|
||||
direction: LEFT
|
||||
- tapOn:
|
||||
id: "profilePager-selector-5"
|
||||
- tapOn:
|
||||
id: "list-Good Ppl"
|
||||
- tapOn: "Good Ppl"
|
||||
|
||||
- tapOn:
|
||||
label: "Adds and removes users on curatelists from the profile"
|
||||
|
||||
@@ -18,8 +18,7 @@ appId: xyz.blueskyweb.app
|
||||
direction: LEFT
|
||||
- tapOn:
|
||||
id: "profilePager-selector-4"
|
||||
- tapOn:
|
||||
id: "feed-alice-favs"
|
||||
- tapOn: "alice-favs"
|
||||
- tapOn: "Pin to Home"
|
||||
- tapOn:
|
||||
id: "bottomBarHomeBtn"
|
||||
|
||||
@@ -23,8 +23,7 @@ appId: xyz.blueskyweb.app
|
||||
direction: LEFT
|
||||
- tapOn:
|
||||
id: "profilePager-selector-4"
|
||||
- tapOn:
|
||||
id: "feed-alice-favs"
|
||||
- tapOn: "alice-favs"
|
||||
- tapOn: "Pin to Home"
|
||||
- tapOn:
|
||||
id: "bottomBarHomeBtn"
|
||||
|
||||
@@ -21,8 +21,7 @@ appId: xyz.blueskyweb.app
|
||||
direction: LEFT
|
||||
- tapOn:
|
||||
id: "profilePager-selector-4"
|
||||
- assertVisible:
|
||||
id: "feed-alice-favs"
|
||||
- assertVisible: "alice-favs"
|
||||
- swipe:
|
||||
from:
|
||||
id: "profilePager-selector"
|
||||
|
||||
+3
-3
@@ -45,9 +45,7 @@ module.exports = function (config) {
|
||||
'appclips:bsky.app',
|
||||
'appclips:go.bsky.app', // Allows App Clip to work when scanning QR codes
|
||||
// When testing local services, enter an ngrok (et al) domain here. It must use a standard HTTP/HTTPS port.
|
||||
...(IS_DEV || IS_TESTFLIGHT
|
||||
? ['appclips:sptesting.haileyok.com', 'applinks:sptesting.haileyok.com']
|
||||
: []),
|
||||
...(IS_DEV || IS_TESTFLIGHT ? [] : []),
|
||||
]
|
||||
|
||||
const UPDATES_CHANNEL = IS_TESTFLIGHT
|
||||
@@ -213,6 +211,8 @@ module.exports = function (config) {
|
||||
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
||||
},
|
||||
],
|
||||
'expo-video',
|
||||
'react-native-compressor',
|
||||
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M21 12a1 1 0 0 1-.293.707l-6 6a1 1 0 0 1-1.414-1.414L17.586 13H4a1 1 0 1 1 0-2h13.586l-4.293-4.293a1 1 0 0 1 1.414-1.414l6 6A1 1 0 0 1 21 12Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 284 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="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 370 B |
@@ -11,6 +11,7 @@ export default function (ctx: AppContext, app: Express) {
|
||||
'/:linkId',
|
||||
handler(async (req, res) => {
|
||||
const linkId = req.params.linkId
|
||||
const contentType = req.accepts(['html', 'json'])
|
||||
assert(
|
||||
typeof linkId === 'string',
|
||||
'express guarantees id parameter is a string',
|
||||
@@ -21,9 +22,19 @@ export default function (ctx: AppContext, app: Express) {
|
||||
.where('id', '=', linkId)
|
||||
.executeTakeFirst()
|
||||
if (!found) {
|
||||
// potentially broken or mistyped link— send user to the app
|
||||
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
||||
// potentially broken or mistyped link
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
if (contentType === 'json') {
|
||||
return res
|
||||
.status(404)
|
||||
.json({
|
||||
error: 'NotFound',
|
||||
message: 'Link not found',
|
||||
})
|
||||
.end()
|
||||
}
|
||||
// send the user to the app
|
||||
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
||||
return res.status(302).end()
|
||||
}
|
||||
// build url from original url in order to preserve query params
|
||||
@@ -32,8 +43,11 @@ export default function (ctx: AppContext, app: Express) {
|
||||
`https://${ctx.cfg.service.appHostname}`,
|
||||
)
|
||||
url.pathname = found.path
|
||||
res.setHeader('Location', url.href)
|
||||
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
|
||||
if (contentType === 'json') {
|
||||
return res.json({url: url.href}).end()
|
||||
}
|
||||
res.setHeader('Location', url.href)
|
||||
return res.status(301).end()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,15 @@ import {AppContext} from '../context.js'
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get('/.well-known/apple-app-site-association', (req, res) => {
|
||||
res.json({
|
||||
applinks: {
|
||||
apps: [],
|
||||
details: [
|
||||
{
|
||||
appID: 'B3LX46C5HS.xyz.blueskyweb.app',
|
||||
paths: ['*'],
|
||||
},
|
||||
],
|
||||
},
|
||||
appclips: {
|
||||
apps: ['B3LX46C5HS.xyz.blueskyweb.app.AppClip'],
|
||||
},
|
||||
|
||||
@@ -56,6 +56,26 @@ describe('link service', async () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('returns json object with url when requested', async () => {
|
||||
const link = await getLink('/start/did:example:carol/zzz/')
|
||||
const [status, json] = await getJsonRedirect(link)
|
||||
assert.strictEqual(status, 200)
|
||||
assert(json.url)
|
||||
const url = new URL(json.url)
|
||||
assert.strictEqual(url.pathname, '/start/did:example:carol/zzz')
|
||||
})
|
||||
|
||||
it('returns 404 for unknown link when requesting json', async () => {
|
||||
const [status, json] = await getJsonRedirect(
|
||||
'https://test.bsky.link/unknown',
|
||||
)
|
||||
assert(json.error)
|
||||
assert(json.message)
|
||||
assert.strictEqual(status, 404)
|
||||
assert.strictEqual(json.error, 'NotFound')
|
||||
assert.strictEqual(json.message, 'Link not found')
|
||||
})
|
||||
|
||||
async function getRedirect(link: string): Promise<[number, string]> {
|
||||
const url = new URL(link)
|
||||
const base = new URL(baseUrl)
|
||||
@@ -70,6 +90,25 @@ describe('link service', async () => {
|
||||
return [res.status, res.headers.get('location') ?? '']
|
||||
}
|
||||
|
||||
async function getJsonRedirect(
|
||||
link: string,
|
||||
): Promise<[number, {url?: string; error?: string; message?: string}]> {
|
||||
const url = new URL(link)
|
||||
const base = new URL(baseUrl)
|
||||
url.protocol = base.protocol
|
||||
url.host = base.host
|
||||
const res = await fetch(url, {
|
||||
redirect: 'manual',
|
||||
headers: {accept: 'application/json,text/html'},
|
||||
})
|
||||
assert(
|
||||
res.headers.get('content-type')?.startsWith('application/json'),
|
||||
'content type was not json',
|
||||
)
|
||||
const json = await res.json()
|
||||
return [res.status, json]
|
||||
}
|
||||
|
||||
async function getLink(path: string): Promise<string> {
|
||||
const res = await fetch(new URL('/link', baseUrl), {
|
||||
method: 'post',
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --loader ts-node/esm ./src/bin.ts",
|
||||
"build": "tsc && cp -r src/assets dist/assets"
|
||||
"dev": "node --watch-path ./src --loader ts-node/esm ./src/bin.ts",
|
||||
"build": "tsc && cp -r src/assets dist/",
|
||||
"install-fonts": "node --loader ts-node/esm scripts/install-fonts.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.12.19-next.0",
|
||||
@@ -15,10 +17,12 @@
|
||||
"http-terminator": "^3.2.0",
|
||||
"pino": "^9.2.0",
|
||||
"react": "^18.3.1",
|
||||
"satori": "^0.10.13"
|
||||
"satori": "^0.10.13",
|
||||
"twemoji": "^14.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {writeFile} from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
|
||||
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const FONTS = [
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-jp@5.0/japanese-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-tc@5.0/chinese-traditional-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-sc@5.0/chinese-simplified-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hk@5.0/chinese-hongkong-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-kr@5.0/korean-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-thai@5.0/thai-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-arabic@5.0/arabic-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hebrew@5.0/hebrew-700-normal.ttf',
|
||||
]
|
||||
|
||||
async function main() {
|
||||
await Promise.all(
|
||||
FONTS.map(async urlStr => {
|
||||
const url = new URL(urlStr)
|
||||
const res = await fetch(url)
|
||||
const font = await res.arrayBuffer()
|
||||
const filename = url.pathname
|
||||
.split('/')
|
||||
.slice(-2)
|
||||
.join('/')
|
||||
.replace(/@[\d.]+\//, '-')
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: fetching failed for ${filename}`)
|
||||
}
|
||||
await writeFile(
|
||||
path.join(__DIRNAME, '..', 'src', 'assets', 'fonts', filename),
|
||||
Buffer.from(font),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -43,6 +43,7 @@ export function StarterPack(props: {
|
||||
} else {
|
||||
imagesAcross.push(...imagesExceptCreator.slice(0, 7))
|
||||
}
|
||||
const isLongTitle = record ? record.name.length > 30 : false
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -130,7 +131,9 @@ export function StarterPack(props: {
|
||||
<div
|
||||
style={{
|
||||
padding: '75px 30px 0px',
|
||||
fontSize: 65,
|
||||
fontSize: isLongTitle ? 55 : 65,
|
||||
display: 'flex',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{record?.name || 'Starter Pack'}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {readFileSync} from 'node:fs'
|
||||
import {readdirSync, readFileSync} from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
import * as path from 'path'
|
||||
import {fileURLToPath} from 'url'
|
||||
|
||||
import {Config} from './config.js'
|
||||
|
||||
@@ -28,12 +28,14 @@ export class AppContext {
|
||||
|
||||
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
|
||||
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
|
||||
const fonts = [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: readFileSync(path.join(__DIRNAME, 'assets', 'Inter-Bold.ttf')),
|
||||
},
|
||||
]
|
||||
const fontDirectory = path.join(__DIRNAME, 'assets', 'fonts')
|
||||
const fontFiles = readdirSync(fontDirectory)
|
||||
const fonts = fontFiles.map(file => {
|
||||
return {
|
||||
name: path.basename(file, path.extname(file)),
|
||||
data: readFileSync(path.join(fontDirectory, file)),
|
||||
}
|
||||
})
|
||||
return new AppContext({
|
||||
cfg,
|
||||
appviewAgent,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {subsystemLogger} from '@atproto/common'
|
||||
|
||||
export const httpLogger = subsystemLogger('bskyogcard')
|
||||
export const renderLogger = subsystemLogger('bskyogcard:render')
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../components/StarterPack.js'
|
||||
import {AppContext} from '../context.js'
|
||||
import {httpLogger} from '../logger.js'
|
||||
import {loadEmojiAsSvg} from '../util.js'
|
||||
import {handler, originVerifyMiddleware} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
@@ -65,6 +66,11 @@ export default function (ctx: AppContext, app: Express) {
|
||||
fonts: ctx.fonts,
|
||||
height: STARTERPACK_HEIGHT,
|
||||
width: STARTERPACK_WIDTH,
|
||||
loadAdditionalAsset: async (code, text) => {
|
||||
if (code === 'emoji') {
|
||||
return await loadEmojiAsSvg(text)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
const output = await resvg.renderAsync(svg)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import twemoji from 'twemoji'
|
||||
|
||||
import {renderLogger} from './logger.js'
|
||||
|
||||
const U200D = String.fromCharCode(0x200d)
|
||||
const UFE0F_REGEXP = /\uFE0F/g
|
||||
|
||||
export async function loadEmojiAsSvg(chars: string) {
|
||||
const cached = emojiCache.get(chars)
|
||||
if (cached) return cached
|
||||
const iconCode = twemoji.convert.toCodePoint(
|
||||
chars.indexOf(U200D) < 0 ? chars.replace(UFE0F_REGEXP, '') : chars,
|
||||
)
|
||||
const res = await fetch(getEmojiUrl(iconCode))
|
||||
const body = await res.arrayBuffer()
|
||||
if (!res.ok) {
|
||||
renderLogger.warn(
|
||||
{status: res.status, err: Buffer.from(body).toString()},
|
||||
'could not fetch emoji',
|
||||
)
|
||||
return
|
||||
}
|
||||
const svg =
|
||||
'data:image/svg+xml;base64,' + Buffer.from(body).toString('base64')
|
||||
emojiCache.set(chars, svg)
|
||||
return svg
|
||||
}
|
||||
|
||||
const emojiCache = new Map<string, string>()
|
||||
|
||||
function getEmojiUrl(code: string) {
|
||||
return (
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/' +
|
||||
code.toLowerCase() +
|
||||
'.svg'
|
||||
)
|
||||
}
|
||||
+303
-147
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -24,7 +24,7 @@ Then build and copy over the big 'ol `bundle.web.js` file:
|
||||
|
||||
### Golang Daemon
|
||||
|
||||
Install golang. We are generally using v1.21+.
|
||||
Install golang. We are generally using v1.22+.
|
||||
|
||||
In this directory (`bskyweb/`):
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ func run(args []string) {
|
||||
// retain old PDS env var for easy transition
|
||||
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ogcard-host",
|
||||
Usage: "scheme, hostname, and port of ogcard service",
|
||||
Required: false,
|
||||
EnvVars: []string{"OGCARD_HOST"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-address",
|
||||
Usage: "Specify the local IP/port to bind to",
|
||||
|
||||
@@ -31,12 +31,22 @@ type Server struct {
|
||||
echo *echo.Echo
|
||||
httpd *http.Server
|
||||
xrpcc *xrpc.Client
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
debug bool
|
||||
httpAddress string
|
||||
appviewHost string
|
||||
ogcardHost string
|
||||
linkHost string
|
||||
}
|
||||
|
||||
func serve(cctx *cli.Context) error {
|
||||
debug := cctx.Bool("debug")
|
||||
httpAddress := cctx.String("http-address")
|
||||
appviewHost := cctx.String("appview-host")
|
||||
ogcardHost := cctx.String("ogcard-host")
|
||||
linkHost := cctx.String("link-host")
|
||||
|
||||
// Echo
|
||||
@@ -73,6 +83,13 @@ func serve(cctx *cli.Context) error {
|
||||
server := &Server{
|
||||
echo: e,
|
||||
xrpcc: xrpcc,
|
||||
cfg: &Config{
|
||||
debug: debug,
|
||||
httpAddress: httpAddress,
|
||||
appviewHost: appviewHost,
|
||||
ogcardHost: ogcardHost,
|
||||
linkHost: linkHost,
|
||||
},
|
||||
}
|
||||
|
||||
// Create the HTTP server.
|
||||
@@ -223,9 +240,9 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
|
||||
|
||||
// starter packs
|
||||
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebGeneric)
|
||||
e.GET("/start/:handleOrDID/:rkey", server.WebGeneric)
|
||||
// starter packs
|
||||
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
|
||||
if linkHost != "" {
|
||||
linkUrl, err := url.Parse(linkHost)
|
||||
@@ -415,6 +432,45 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebStarterPack(c echo.Context) error {
|
||||
req := c.Request()
|
||||
ctx := req.Context()
|
||||
data := pongo2.Context{}
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
// sanity check arguments. don't 4xx, just let app handle if not expected format
|
||||
rkeyParam := c.Param("rkey")
|
||||
rkey, err := syntax.ParseRecordKey(rkeyParam)
|
||||
if err != nil {
|
||||
log.Errorf("bad rkey: %v", err)
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
handleOrDIDParam := c.Param("handleOrDID")
|
||||
handleOrDID, err := syntax.ParseAtIdentifier(handleOrDIDParam)
|
||||
if err != nil {
|
||||
log.Errorf("bad identifier: %v", err)
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
identifier := handleOrDID.Normalize().String()
|
||||
starterPackURI := fmt.Sprintf("at://%s/app.bsky.graph.starterpack/%s", identifier, rkey)
|
||||
spv, err := appbsky.GraphGetStarterPack(ctx, srv.xrpcc, starterPackURI)
|
||||
if err != nil {
|
||||
log.Errorf("failed to fetch starter pack view for: %s\t%v", starterPackURI, err)
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
if spv.StarterPack == nil || spv.StarterPack.Record == nil {
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
rec, ok := spv.StarterPack.Record.Val.(*appbsky.GraphStarterpack)
|
||||
if !ok {
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
data["title"] = rec.Name
|
||||
if srv.cfg.ogcardHost != "" {
|
||||
data["imgThumbUrl"] = fmt.Sprintf("%s/start/%s/%s", srv.cfg.ogcardHost, identifier, rkey)
|
||||
}
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebProfile(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
data := pongo2.Context{}
|
||||
|
||||
+16
-14
@@ -1,9 +1,11 @@
|
||||
module github.com/bluesky-social/social-app/bskyweb
|
||||
|
||||
go 1.21
|
||||
go 1.22
|
||||
|
||||
toolchain go1.22.4
|
||||
|
||||
require (
|
||||
github.com/bluesky-social/indigo v0.0.0-20231216010655-ad730a7da4f5
|
||||
github.com/bluesky-social/indigo v0.0.0-20240624223826-fd66f93ce141
|
||||
github.com/flosch/pongo2/v6 v6.0.0
|
||||
github.com/ipfs/go-log v1.0.5
|
||||
github.com/joho/godotenv v1.5.1
|
||||
@@ -19,7 +21,7 @@ require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.3.0 // indirect
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
@@ -48,7 +50,7 @@ require (
|
||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/labstack/gommon v0.4.1 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
@@ -58,10 +60,9 @@ require (
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.18 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||
@@ -69,6 +70,7 @@ require (
|
||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
github.com/orandin/slog-gorm v1.3.2 // indirect
|
||||
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
|
||||
github.com/prometheus/client_golang v1.17.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
@@ -79,28 +81,28 @@ require (
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230923211252-36a87e1ba72f // indirect
|
||||
github.com/whyrusleeping/cbor-gen v0.1.1-0.20240311221002-68b9f235c302 // indirect
|
||||
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
|
||||
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.42.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
|
||||
go.opentelemetry.io/otel v1.21.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.21.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.21.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
golang.org/x/crypto v0.15.0 // indirect
|
||||
golang.org/x/net v0.18.0 // indirect
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/sync v0.5.0 // indirect
|
||||
golang.org/x/sys v0.14.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gorm.io/driver/postgres v1.5.0 // indirect
|
||||
gorm.io/driver/sqlite v1.5.4 // indirect
|
||||
gorm.io/gorm v1.25.5 // indirect
|
||||
gorm.io/driver/postgres v1.5.7 // indirect
|
||||
gorm.io/driver/sqlite v1.5.5 // indirect
|
||||
gorm.io/gorm v1.25.9 // indirect
|
||||
lukechampine.com/blake3 v1.2.1 // indirect
|
||||
)
|
||||
|
||||
+28
-48
@@ -2,8 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bluesky-social/indigo v0.0.0-20231216010655-ad730a7da4f5 h1:Zk1c+mxCYH6G/vLL0+9lO2Eci4OT3AFy73qPWa9auDM=
|
||||
github.com/bluesky-social/indigo v0.0.0-20231216010655-ad730a7da4f5/go.mod h1:a8cPbqDkRX+aPwJnXF7kAi3PF26hYiR4w5H8624MB7k=
|
||||
github.com/bluesky-social/indigo v0.0.0-20240624223826-fd66f93ce141 h1:F3ZceqS82fFfVV3ychWRLNDRFaFlbOwrPic0qJiUVqw=
|
||||
github.com/bluesky-social/indigo v0.0.0-20240624223826-fd66f93ce141/go.mod h1:dBIOGhsiK0rgEETnxiGiuEyrSx6DKxEotHIPbiKD6WU=
|
||||
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
|
||||
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
@@ -11,7 +11,6 @@ github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -23,8 +22,8 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw
|
||||
github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
|
||||
github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
|
||||
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
|
||||
@@ -35,7 +34,6 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
@@ -60,7 +58,6 @@ github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
|
||||
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
|
||||
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
|
||||
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
|
||||
github.com/ipfs/go-cid v0.0.6/go.mod h1:6Ux9z5e+HpkQdckYoX1PG/6xqKspzlEIR5SDmgqgC/I=
|
||||
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
|
||||
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
|
||||
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
|
||||
@@ -88,10 +85,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
|
||||
github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw=
|
||||
github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||
github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
|
||||
@@ -109,11 +104,9 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
|
||||
github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -143,37 +136,28 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||
github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ=
|
||||
github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM=
|
||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mr-tron/base58 v1.1.0/go.mod h1:xcD2VGqlgYjBdcBLw+TuYLr8afG+Hj8g2eTVqeSzSU8=
|
||||
github.com/mr-tron/base58 v1.1.3/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/multiformats/go-base32 v0.0.3/go.mod h1:pLiuGC8y0QR3Ue4Zug5UzK9LjgbkL8NSQj0zQ5Nz/AA=
|
||||
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
|
||||
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
|
||||
github.com/multiformats/go-base36 v0.1.0/go.mod h1:kFGE83c6s80PklsHO9sRn2NCoffoRdUUOENyW/Vv6sM=
|
||||
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
|
||||
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
|
||||
github.com/multiformats/go-multibase v0.0.3/go.mod h1:5+1R4eQrT3PkYZ24C3W2Ue2tPwIdYQD509ZjSb5y9Oc=
|
||||
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
|
||||
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
|
||||
github.com/multiformats/go-multihash v0.0.13/go.mod h1:VdAWLKTwram9oKAatUcLxBNUjdtcVwxObEQBtRfuyjc=
|
||||
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
|
||||
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
||||
github.com/multiformats/go-varint v0.0.5/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
|
||||
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
|
||||
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
|
||||
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
|
||||
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
|
||||
github.com/orandin/slog-gorm v1.3.2 h1:C0lKDQPAx/pF+8K2HL7bdShPwOEJpPM0Bn80zTzxU1g=
|
||||
github.com/orandin/slog-gorm v1.3.2/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -188,7 +172,6 @@ github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGy
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -213,9 +196,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
|
||||
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||
@@ -225,8 +208,8 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
|
||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
|
||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230923211252-36a87e1ba72f h1:SBuSxXJL0/ZJMtTxbXZgHZkThl9dNrzyaNhlyaqscRo=
|
||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230923211252-36a87e1ba72f/go.mod h1:fgkXqYy7bV2cFeIEOkVTZS/WjXARfBqSH6Q2qHL33hQ=
|
||||
github.com/whyrusleeping/cbor-gen v0.1.1-0.20240311221002-68b9f235c302 h1:MhInbXe4SzcImAKktUvWBCWZgcw6MYf5NfumTj1BhAw=
|
||||
github.com/whyrusleeping/cbor-gen v0.1.1-0.20240311221002-68b9f235c302/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
|
||||
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 h1:yJ9/LwIGIk/c0CdoavpC9RNSGSruIspSZtxG3Nnldic=
|
||||
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
@@ -239,8 +222,8 @@ gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRyS
|
||||
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
|
||||
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
|
||||
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.42.0 h1:pginetY7+onl4qN1vl0xW/V/v6OBZ0vVdH+esuJgvmM=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.42.0/go.mod h1:XiYsayHc36K3EByOO6nbAXnAWbrUxdjUROCEeeROOH8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
|
||||
go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc=
|
||||
go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo=
|
||||
go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4=
|
||||
@@ -265,14 +248,12 @@ go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
|
||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -290,8 +271,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
|
||||
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
|
||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -316,8 +297,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
|
||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -365,13 +346,12 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
||||
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
||||
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
||||
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
|
||||
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
||||
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
|
||||
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
|
||||
gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8=
|
||||
gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI=
|
||||
lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"applinks": {
|
||||
"appclips": {
|
||||
"apps": ["B3LX46C5HS.xyz.blueskyweb.app.AppClip"]
|
||||
},
|
||||
"details": [
|
||||
{
|
||||
"appID": "B3LX46C5HS.xyz.blueskyweb.app",
|
||||
@@ -11,5 +8,8 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"appclips": {
|
||||
"apps": ["B3LX46C5HS.xyz.blueskyweb.app.AppClip"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block html_head_extra -%}
|
||||
<meta name="apple-itunes-app" content="app-id=xyz.blueskyweb.app, app-clip-bundle-id=xyz.blueskyweb.app.AppClip, app-clip-display=card">
|
||||
{%- if requestURI %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
{% endif -%}
|
||||
{%- if imgThumbUrl %}
|
||||
<meta property="og:image" content="{{ imgThumbUrl }}">
|
||||
<meta property="twitter:image" content="{{ imgThumbUrl }}">
|
||||
{%- else -%}
|
||||
<meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
<meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
{% endif -%}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{%- if title %}
|
||||
<meta property="og:title" content="{{ title }}" />
|
||||
<meta name="twitter:title" content="{{ title }}" />
|
||||
{%- else -%}
|
||||
<meta property="og:title" content="Bluesky" />
|
||||
<meta name="twitter:title" content="Bluesky" />
|
||||
{% endif -%}
|
||||
<meta name="description" content="Join the conversation" />
|
||||
<meta name="og:description" content="Join the conversation" />
|
||||
<meta name="twitter:description" content="Join the conversation" />
|
||||
{%- endblock %}
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.87.0",
|
||||
"version": "1.88.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -49,7 +49,7 @@
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.12.22-next.0",
|
||||
"@atproto/api": "^0.12.23",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
@@ -136,6 +136,7 @@
|
||||
"expo-system-ui": "~3.0.4",
|
||||
"expo-task-manager": "~11.8.1",
|
||||
"expo-updates": "~0.25.14",
|
||||
"expo-video": "^1.1.10",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
@@ -166,6 +167,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-keyed-flatten-children": "^3.0.0",
|
||||
"react-native": "0.74.1",
|
||||
"react-native-compressor": "^1.8.24",
|
||||
"react-native-date-picker": "^4.4.2",
|
||||
"react-native-drawer-layout": "^4.0.0-alpha.3",
|
||||
"react-native-fs": "^2.20.0",
|
||||
@@ -193,6 +195,7 @@
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"rn-fetch-blob": "^0.12.0",
|
||||
"rn-tourguide": "bluesky-social/rn-tourguide",
|
||||
"sentry-expo": "~7.0.1",
|
||||
"statsig-react-native-expo": "^4.6.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
|
||||
@@ -26,6 +26,14 @@ const withClipInfoPlist = (config, {targetName}) => {
|
||||
CFBundleShortVersionString: config.version,
|
||||
CFBundleIconName: 'AppIcon',
|
||||
UIViewControllerBasedStatusBarAppearance: 'NO',
|
||||
UISupportedInterfaceOrientations: [
|
||||
'UIInterfaceOrientationPortrait',
|
||||
'UIInterfaceOrientationPortraitUpsideDown',
|
||||
],
|
||||
'UISupportedInterfaceOrientations~ipad': [
|
||||
'UIInterfaceOrientationPortrait',
|
||||
'UIInterfaceOrientationPortraitUpsideDown',
|
||||
],
|
||||
})
|
||||
|
||||
fs.mkdirSync(path.dirname(targetPath), {recursive: true})
|
||||
|
||||
+11
-4
@@ -45,6 +45,7 @@ import {
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
||||
@@ -55,6 +56,7 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Splash} from '#/Splash'
|
||||
import {Provider as TourProvider} from '#/tours'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
@@ -117,10 +119,15 @@ function InnerApp() {
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
<TourProvider>
|
||||
<ProgressGuideProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</ProgressGuideProvider>
|
||||
</TourProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
|
||||
+7
-1
@@ -34,6 +34,7 @@ import {
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
@@ -43,6 +44,7 @@ import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as TourProvider} from '#/tours'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
@@ -102,7 +104,11 @@ function InnerApp() {
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
<TourProvider>
|
||||
<ProgressGuideProvider>
|
||||
<Shell />
|
||||
</ProgressGuideProvider>
|
||||
</TourProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
|
||||
+10
-2
@@ -43,7 +43,10 @@ import HashtagScreen from '#/screens/Hashtag'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||
import {StarterPackScreen} from '#/screens/StarterPack/StarterPackScreen'
|
||||
import {
|
||||
StarterPackScreen,
|
||||
StarterPackScreenShort,
|
||||
} from '#/screens/StarterPack/StarterPackScreen'
|
||||
import {Wizard} from '#/screens/StarterPack/Wizard'
|
||||
import {init as initAnalytics} from './lib/analytics/analytics'
|
||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
@@ -322,7 +325,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
<Stack.Screen
|
||||
name="StarterPack"
|
||||
getComponent={() => StarterPackScreen}
|
||||
options={{title: title(msg`Starter Pack`), requireAuth: true}}
|
||||
options={{title: title(msg`Starter Pack`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="StarterPackShort"
|
||||
getComponent={() => StarterPackScreenShort}
|
||||
options={{title: title(msg`Starter Pack`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="StarterPackWizard"
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
|
||||
export type ButtonColor =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'secondary_inverted'
|
||||
| 'negative'
|
||||
| 'gradient_sky'
|
||||
| 'gradient_midnight'
|
||||
@@ -217,6 +218,43 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
borderWidth: 1,
|
||||
})
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.contrast_300,
|
||||
})
|
||||
hoverStyles.push(t.atoms.bg_contrast_50)
|
||||
} else {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.contrast_200,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
if (!disabled) {
|
||||
baseStyles.push(t.atoms.bg)
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.contrast_25,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (color === 'secondary_inverted') {
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.contrast_900,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.contrast_950,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.contrast_700,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
baseStyles.push(a.border, t.atoms.bg, {
|
||||
borderWidth: 1,
|
||||
})
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.contrast_300,
|
||||
@@ -344,6 +382,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
const gradient = {
|
||||
primary: tokens.gradients.sky,
|
||||
secondary: tokens.gradients.sky,
|
||||
secondary_inverted: tokens.gradients.sky,
|
||||
negative: tokens.gradients.sky,
|
||||
gradient_sky: tokens.gradients.sky,
|
||||
gradient_midnight: tokens.gradients.midnight,
|
||||
@@ -499,6 +538,38 @@ export function useSharedButtonTextStyles() {
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (color === 'secondary_inverted') {
|
||||
if (variant === 'solid' || variant === 'gradient') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
color: t.palette.contrast_100,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
color: t.palette.contrast_400,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
color: t.palette.contrast_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
color: t.palette.contrast_300,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
color: t.palette.contrast_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
color: t.palette.contrast_300,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (color === 'negative') {
|
||||
if (variant === 'solid' || variant === 'gradient') {
|
||||
if (!disabled) {
|
||||
|
||||
@@ -88,7 +88,10 @@ export function Outer({
|
||||
if (!isOpen) return
|
||||
|
||||
function handler(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') close()
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handler)
|
||||
|
||||
+67
-81
@@ -5,6 +5,7 @@ import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyGraphDefs,
|
||||
AtUri,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
import {msg, plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -17,61 +18,46 @@ import {
|
||||
useRemoveFeedMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {precacheFeedFromGeneratorView, precacheList} from 'state/queries/feed'
|
||||
import {precacheFeedFromGeneratorView} from 'state/queries/feed'
|
||||
import {useSession} from 'state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {useRichText} from '#/components/hooks/useRichText'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {RichText, RichTextProps} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type Props =
|
||||
| {
|
||||
type: 'feed'
|
||||
view: AppBskyFeedDefs.GeneratorView
|
||||
}
|
||||
| {
|
||||
type: 'list'
|
||||
view: AppBskyGraphDefs.ListView
|
||||
}
|
||||
type Props = {
|
||||
view: AppBskyFeedDefs.GeneratorView
|
||||
}
|
||||
|
||||
export function Default(props: Props) {
|
||||
const {type, view} = props
|
||||
const displayName = type === 'feed' ? view.displayName : view.name
|
||||
const purpose = type === 'list' ? view.purpose : undefined
|
||||
const {view} = props
|
||||
return (
|
||||
<Link label={displayName} {...props}>
|
||||
<Link label={view.displayName} {...props}>
|
||||
<Outer>
|
||||
<Header>
|
||||
<Avatar src={view.avatar} />
|
||||
<TitleAndByline
|
||||
title={displayName}
|
||||
creator={view.creator}
|
||||
type={type}
|
||||
purpose={purpose}
|
||||
/>
|
||||
<Action uri={view.uri} pin type={type} purpose={purpose} />
|
||||
<TitleAndByline title={view.displayName} creator={view.creator} />
|
||||
<SaveButton view={view} pin />
|
||||
</Header>
|
||||
<Description description={view.description} />
|
||||
{type === 'feed' && <Likes count={view.likeCount || 0} />}
|
||||
<Likes count={view.likeCount || 0} />
|
||||
</Outer>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Link({
|
||||
type,
|
||||
view,
|
||||
label,
|
||||
children,
|
||||
...props
|
||||
}: Props & Omit<LinkProps, 'to'>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -79,32 +65,23 @@ export function Link({
|
||||
return createProfileFeedHref({feed: view})
|
||||
}, [view])
|
||||
|
||||
React.useEffect(() => {
|
||||
precacheFeedFromGeneratorView(queryClient, view)
|
||||
}, [view, queryClient])
|
||||
|
||||
return (
|
||||
<InternalLink
|
||||
to={href}
|
||||
label={label}
|
||||
onPress={() => {
|
||||
if (type === 'feed') {
|
||||
precacheFeedFromGeneratorView(queryClient, view)
|
||||
} else {
|
||||
precacheList(queryClient, view)
|
||||
}
|
||||
}}>
|
||||
<InternalLink to={href} style={[a.flex_col]} {...props}>
|
||||
{children}
|
||||
</InternalLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function Outer({children}: {children: React.ReactNode}) {
|
||||
return <View style={[a.flex_1, a.gap_md]}>{children}</View>
|
||||
return <View style={[a.w_full, a.gap_md]}>{children}</View>
|
||||
}
|
||||
|
||||
export function Header({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_md]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
|
||||
}
|
||||
|
||||
export type AvatarProps = {src: string | undefined; size?: number}
|
||||
@@ -132,13 +109,9 @@ export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
|
||||
export function TitleAndByline({
|
||||
title,
|
||||
creator,
|
||||
type,
|
||||
purpose,
|
||||
}: {
|
||||
title: string
|
||||
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||
type: 'feed' | 'list'
|
||||
purpose?: AppBskyGraphDefs.ListView['purpose']
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -151,15 +124,7 @@ export function TitleAndByline({
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{type === 'list' && purpose === 'app.bsky.graph.defs#curatelist' ? (
|
||||
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||
) : type === 'list' && purpose === 'app.bsky.graph.defs#modlist' ? (
|
||||
<Trans>
|
||||
Moderation list by {sanitizeHandle(creator.handle, '@')}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||
)}
|
||||
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -198,13 +163,39 @@ export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Description({description}: {description?: string}) {
|
||||
const [rt, isResolving] = useRichText(description || '')
|
||||
if (!description) return null
|
||||
return isResolving ? (
|
||||
<RichText value={description} style={[a.leading_snug]} />
|
||||
) : (
|
||||
<RichText value={rt} style={[a.leading_snug]} />
|
||||
export function Description({
|
||||
description,
|
||||
...rest
|
||||
}: {description?: string} & Partial<RichTextProps>) {
|
||||
const rt = React.useMemo(() => {
|
||||
if (!description) return
|
||||
const rt = new RichTextApi({text: description || ''})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
return rt
|
||||
}, [description])
|
||||
if (!rt) return null
|
||||
return <RichText value={rt} style={[a.leading_snug]} disableLinks {...rest} />
|
||||
}
|
||||
|
||||
export function DescriptionPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.gap_xs]}>
|
||||
<View
|
||||
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||
/>
|
||||
<View
|
||||
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
a.w_full,
|
||||
t.atoms.bg_contrast_50,
|
||||
{height: 12, width: 100},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -220,30 +211,24 @@ export function Likes({count}: {count: number}) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Action({
|
||||
uri,
|
||||
export function SaveButton({
|
||||
view,
|
||||
pin,
|
||||
type,
|
||||
purpose,
|
||||
}: {
|
||||
uri: string
|
||||
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||
pin?: boolean
|
||||
type: 'feed' | 'list'
|
||||
purpose?: AppBskyGraphDefs.ListView['purpose']
|
||||
}) {
|
||||
const {hasSession} = useSession()
|
||||
if (!hasSession || purpose !== 'app.bsky.graph.defs#curatelist') return null
|
||||
return <ActionInner uri={uri} pin={pin} type={type} />
|
||||
if (!hasSession) return null
|
||||
return <SaveButtonInner view={view} pin={pin} />
|
||||
}
|
||||
|
||||
function ActionInner({
|
||||
uri,
|
||||
function SaveButtonInner({
|
||||
view,
|
||||
pin,
|
||||
type,
|
||||
}: {
|
||||
uri: string
|
||||
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||
pin?: boolean
|
||||
type: 'feed' | 'list'
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
@@ -251,6 +236,10 @@ function ActionInner({
|
||||
useAddSavedFeedsMutation()
|
||||
const {isPending: isRemovePending, mutateAsync: removeFeed} =
|
||||
useRemoveFeedMutation()
|
||||
|
||||
const uri = view.uri
|
||||
const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
|
||||
|
||||
const savedFeedConfig = React.useMemo(() => {
|
||||
return preferences?.savedFeeds?.find(feed => feed.value === uri)
|
||||
}, [preferences?.savedFeeds, uri])
|
||||
@@ -327,12 +316,9 @@ function ActionInner({
|
||||
export function createProfileFeedHref({
|
||||
feed,
|
||||
}: {
|
||||
feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||
feed: AppBskyFeedDefs.GeneratorView
|
||||
}) {
|
||||
const urip = new AtUri(feed.uri)
|
||||
const type = urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'list'
|
||||
const handleOrDid = feed.creator.handle || feed.creator.did
|
||||
return `/profile/${handleOrDid}/${type === 'feed' ? 'feed' : 'lists'}/${
|
||||
urip.rkey
|
||||
}`
|
||||
return `/profile/${handleOrDid}/feed/${urip.rkey}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ScrollView} from 'react-native-gesture-handler'
|
||||
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||
import {useProfilesQuery} from '#/state/queries/profile'
|
||||
import {useProgressGuide} from '#/state/shell/progress-guide'
|
||||
import * as userActionHistory from '#/state/userActionHistory'
|
||||
import {SeenPost} from '#/state/userActionHistory'
|
||||
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as FeedCard from '#/components/FeedCard'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||
import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ProgressGuideList} from './ProgressGuide/List'
|
||||
|
||||
const MOBILE_CARD_WIDTH = 300
|
||||
|
||||
function CardOuter({
|
||||
children,
|
||||
style,
|
||||
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.p_lg,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
!gtMobile && {
|
||||
width: MOBILE_CARD_WIDTH,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuggestedFollowPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.AvatarPlaceholder />
|
||||
</ProfileCard.Header>
|
||||
|
||||
<View style={[a.py_xs]}>
|
||||
<ProfileCard.NameAndHandlePlaceholder />
|
||||
</View>
|
||||
|
||||
<ProfileCard.DescriptionPlaceholder />
|
||||
</CardOuter>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuggestedFeedsCardPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
||||
<FeedCard.Header>
|
||||
<FeedCard.AvatarPlaceholder />
|
||||
<FeedCard.TitleAndBylinePlaceholder creator />
|
||||
</FeedCard.Header>
|
||||
|
||||
<FeedCard.DescriptionPlaceholder />
|
||||
</CardOuter>
|
||||
)
|
||||
}
|
||||
|
||||
function getRank(seenPost: SeenPost): string {
|
||||
let tier: string
|
||||
if (seenPost.feedContext === 'popfriends') {
|
||||
tier = 'a'
|
||||
} else if (seenPost.feedContext?.startsWith('cluster')) {
|
||||
tier = 'b'
|
||||
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
||||
tier = 'c'
|
||||
} else if (seenPost.feedContext?.startsWith('t-')) {
|
||||
tier = 'd'
|
||||
} else if (seenPost.feedContext === 'nettop') {
|
||||
tier = 'e'
|
||||
} else {
|
||||
tier = 'f'
|
||||
}
|
||||
let score = Math.round(
|
||||
Math.log(
|
||||
1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount,
|
||||
),
|
||||
)
|
||||
if (seenPost.isFollowedBy || Math.random() > 0.9) {
|
||||
score *= 2
|
||||
}
|
||||
const rank = 100 - score
|
||||
return `${tier}-${rank}`
|
||||
}
|
||||
|
||||
function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
|
||||
const rankA = getRank(postA)
|
||||
const rankB = getRank(postB)
|
||||
// Yes, we're comparing strings here.
|
||||
// The "larger" string means a worse rank.
|
||||
if (rankA > rankB) {
|
||||
return 1
|
||||
} else if (rankA < rankB) {
|
||||
return -1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function useExperimentalSuggestedUsersQuery() {
|
||||
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
|
||||
const dids = React.useMemo(() => {
|
||||
const {likes, follows, seen} = userActionSnapshot
|
||||
const likeDids = likes
|
||||
.map(l => new AtUri(l))
|
||||
.map(uri => uri.host)
|
||||
.filter(did => !follows.includes(did))
|
||||
const seenDids = seen
|
||||
.sort(sortSeenPosts)
|
||||
.map(l => new AtUri(l.uri))
|
||||
.map(uri => uri.host)
|
||||
return [...new Set([...likeDids, ...seenDids])]
|
||||
}, [userActionSnapshot])
|
||||
const {data, isLoading, error} = useProfilesQuery({
|
||||
handles: dids.slice(0, 16),
|
||||
})
|
||||
|
||||
const profiles = data
|
||||
? data.profiles.filter(profile => {
|
||||
return !profile.viewer?.following
|
||||
})
|
||||
: []
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
profiles: profiles.slice(0, 6),
|
||||
}
|
||||
}
|
||||
|
||||
export function SuggestedFollows() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {
|
||||
isLoading: isSuggestionsLoading,
|
||||
profiles,
|
||||
error,
|
||||
} = useExperimentalSuggestedUsersQuery()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||
const maxLength = gtMobile ? 4 : 6
|
||||
|
||||
const content = isLoading ? (
|
||||
Array(maxLength)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}])]}>
|
||||
<SuggestedFollowPlaceholder />
|
||||
</View>
|
||||
))
|
||||
) : error || !profiles.length ? null : (
|
||||
<>
|
||||
{profiles.slice(0, maxLength).map(profile => (
|
||||
<ProfileCard.Link
|
||||
key={profile.did}
|
||||
did={profile.handle}
|
||||
onPress={() => {
|
||||
logEvent('feed:interstitial:profileCard:press', {})
|
||||
}}
|
||||
style={[
|
||||
a.flex_1,
|
||||
gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}]),
|
||||
]}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[
|
||||
a.flex_1,
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<ProfileCard.Outer>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.NameAndHandle
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
color="secondary_inverted"
|
||||
shape="round"
|
||||
/>
|
||||
</ProfileCard.Header>
|
||||
<ProfileCard.Description profile={profile} />
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
</ProfileCard.Link>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
if (error || (!isLoading && profiles.length < 4)) {
|
||||
logger.debug(`Not enough profiles to show suggested follows`)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
|
||||
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Suggested for you</Trans>
|
||||
</Text>
|
||||
<Person fill={t.atoms.text_contrast_low.color} />
|
||||
</View>
|
||||
|
||||
{gtMobile ? (
|
||||
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
|
||||
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
|
||||
{content}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_end,
|
||||
a.align_center,
|
||||
a.pt_xs,
|
||||
a.gap_md,
|
||||
]}>
|
||||
<InlineLinkText to="/search" style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Browse more suggestions</Trans>
|
||||
</InlineLinkText>
|
||||
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
|
||||
{content}
|
||||
|
||||
<Button
|
||||
label={_(msg`Browse more accounts on the Explore page`)}
|
||||
onPress={() => {
|
||||
navigation.navigate('SearchTab')
|
||||
}}>
|
||||
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
|
||||
<View style={[a.flex_1, a.justify_center]}>
|
||||
<View style={[a.flex_row, a.px_lg]}>
|
||||
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
|
||||
<Trans>Browse more suggestions on the Explore page</Trans>
|
||||
</Text>
|
||||
|
||||
<Arrow size="xl" />
|
||||
</View>
|
||||
</View>
|
||||
</CardOuter>
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuggestedFeeds() {
|
||||
const numFeedsToDisplay = 3
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {data, isLoading, error} = useGetPopularFeedsQuery({
|
||||
limit: numFeedsToDisplay,
|
||||
})
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const feeds = React.useMemo(() => {
|
||||
const items: AppBskyFeedDefs.GeneratorView[] = []
|
||||
|
||||
if (!data) return items
|
||||
|
||||
for (const page of data.pages) {
|
||||
for (const feed of page.feeds) {
|
||||
items.push(feed)
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}, [data])
|
||||
|
||||
const content = isLoading ? (
|
||||
Array(numFeedsToDisplay)
|
||||
.fill(0)
|
||||
.map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />)
|
||||
) : error || !feeds ? null : (
|
||||
<>
|
||||
{feeds.slice(0, numFeedsToDisplay).map(feed => (
|
||||
<FeedCard.Link
|
||||
key={feed.uri}
|
||||
view={feed}
|
||||
onPress={() => {
|
||||
logEvent('feed:interstitial:feedCard:press', {})
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[
|
||||
a.flex_1,
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<FeedCard.Outer>
|
||||
<FeedCard.Header>
|
||||
<FeedCard.Avatar src={feed.avatar} />
|
||||
<FeedCard.TitleAndByline
|
||||
title={feed.displayName}
|
||||
creator={feed.creator}
|
||||
/>
|
||||
</FeedCard.Header>
|
||||
<FeedCard.Description
|
||||
description={feed.description}
|
||||
numberOfLines={3}
|
||||
/>
|
||||
</FeedCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
</FeedCard.Link>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
return error ? null : (
|
||||
<View
|
||||
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
|
||||
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Some other feeds you might like</Trans>
|
||||
</Text>
|
||||
<Hashtag fill={t.atoms.text_contrast_low.color} />
|
||||
</View>
|
||||
|
||||
{gtMobile ? (
|
||||
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
|
||||
{content}
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_end,
|
||||
a.align_center,
|
||||
a.pt_xs,
|
||||
a.gap_md,
|
||||
]}>
|
||||
<InlineLinkText to="/search" style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Browse more suggestions</Trans>
|
||||
</InlineLinkText>
|
||||
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
|
||||
{content}
|
||||
|
||||
<Button
|
||||
label={_(msg`Browse more feeds on the Explore page`)}
|
||||
onPress={() => {
|
||||
navigation.navigate('SearchTab')
|
||||
}}
|
||||
style={[a.flex_col]}>
|
||||
<CardOuter style={[a.flex_1]}>
|
||||
<View style={[a.flex_1, a.justify_center]}>
|
||||
<View style={[a.flex_row, a.px_lg]}>
|
||||
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
|
||||
<Trans>Browse more suggestions on the Explore page</Trans>
|
||||
</Text>
|
||||
|
||||
<Arrow size="xl" />
|
||||
</View>
|
||||
</View>
|
||||
</CardOuter>
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProgressGuide() {
|
||||
const t = useTheme()
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
const guide = useProgressGuide('like-10-and-follow-7')
|
||||
|
||||
if (isDesktop) {
|
||||
return null
|
||||
}
|
||||
|
||||
return guide ? (
|
||||
<View
|
||||
style={[
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
a.pb_lg,
|
||||
]}>
|
||||
<ProgressGuideList />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
+48
-1
@@ -1,5 +1,10 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent} from 'react-native'
|
||||
import {
|
||||
GestureResponderEvent,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {StackActions, useLinkProps} from '@react-navigation/native'
|
||||
|
||||
@@ -323,3 +328,45 @@ export function InlineLinkText({
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Pressable that uses useLink to handle navigation. It is unstyled, so can be used in cases where the Button styles
|
||||
* in Link are not desired.
|
||||
* @param displayText
|
||||
* @param style
|
||||
* @param children
|
||||
* @param rest
|
||||
* @constructor
|
||||
*/
|
||||
export function BaseLink({
|
||||
displayText,
|
||||
onPress: onPressOuter,
|
||||
style,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
children: React.ReactNode
|
||||
to: string
|
||||
action: 'push' | 'replace' | 'navigate'
|
||||
onPress?: () => false | void
|
||||
shareOnLongPress?: boolean
|
||||
label: string
|
||||
displayText?: string
|
||||
}) {
|
||||
const {onPress, ...btnProps} = useLink({
|
||||
displayText: displayText ?? rest.to,
|
||||
...rest,
|
||||
})
|
||||
return (
|
||||
<Pressable
|
||||
style={style}
|
||||
onPress={e => {
|
||||
onPressOuter?.()
|
||||
onPress(e)
|
||||
}}
|
||||
{...btnProps}>
|
||||
{children}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {precacheList} from 'state/queries/feed'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {
|
||||
Avatar,
|
||||
Description,
|
||||
Header,
|
||||
Outer,
|
||||
SaveButton,
|
||||
} from '#/components/FeedCard'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
/*
|
||||
* This component is based on `FeedCard` and is tightly coupled with that
|
||||
* component. Please refer to `FeedCard` for more context.
|
||||
*/
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarPlaceholder,
|
||||
Description,
|
||||
Header,
|
||||
Outer,
|
||||
SaveButton,
|
||||
TitleAndBylinePlaceholder,
|
||||
} from '#/components/FeedCard'
|
||||
|
||||
const CURATELIST = 'app.bsky.graph.defs#curatelist'
|
||||
const MODLIST = 'app.bsky.graph.defs#modlist'
|
||||
|
||||
type Props = {
|
||||
view: AppBskyGraphDefs.ListView
|
||||
showPinButton?: boolean
|
||||
}
|
||||
|
||||
export function Default(props: Props) {
|
||||
const {view, showPinButton} = props
|
||||
return (
|
||||
<Link label={view.name} {...props}>
|
||||
<Outer>
|
||||
<Header>
|
||||
<Avatar src={view.avatar} />
|
||||
<TitleAndByline
|
||||
title={view.name}
|
||||
creator={view.creator}
|
||||
purpose={view.purpose}
|
||||
/>
|
||||
{showPinButton && view.purpose === CURATELIST && (
|
||||
<SaveButton view={view} pin />
|
||||
)}
|
||||
</Header>
|
||||
<Description description={view.description} />
|
||||
</Outer>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Link({
|
||||
view,
|
||||
children,
|
||||
...props
|
||||
}: Props & Omit<LinkProps, 'to'>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const href = React.useMemo(() => {
|
||||
return createProfileListHref({list: view})
|
||||
}, [view])
|
||||
|
||||
React.useEffect(() => {
|
||||
precacheList(queryClient, view)
|
||||
}, [view, queryClient])
|
||||
|
||||
return (
|
||||
<InternalLink to={href} {...props}>
|
||||
{children}
|
||||
</InternalLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitleAndByline({
|
||||
title,
|
||||
creator,
|
||||
purpose = CURATELIST,
|
||||
}: {
|
||||
title: string
|
||||
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||
purpose?: AppBskyGraphDefs.ListView['purpose']
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{creator && (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{purpose === MODLIST ? (
|
||||
<Trans>
|
||||
Moderation list by {sanitizeHandle(creator.handle, '@')}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function createProfileListHref({
|
||||
list,
|
||||
}: {
|
||||
list: AppBskyGraphDefs.ListView
|
||||
}) {
|
||||
const urip = new AtUri(list.uri)
|
||||
const handleOrDid = list.creator.handle || list.creator.did
|
||||
return `/profile/${handleOrDid}/lists/${urip.rkey}`
|
||||
}
|
||||
@@ -6,10 +6,11 @@ import {useLingui} from '@lingui/react'
|
||||
import {differenceInSeconds} from 'date-fns'
|
||||
|
||||
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useSession} from 'state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
@@ -28,16 +29,27 @@ export function NewskieDialog({
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {currentAccount} = useSession()
|
||||
const timeAgo = useGetTimeAgo()
|
||||
const control = useDialogControl()
|
||||
|
||||
const isMe = profile.did === currentAccount?.did
|
||||
const createdAt = profile.createdAt as string | undefined
|
||||
|
||||
const profileName = React.useMemo(() => {
|
||||
const name = profile.displayName || profile.handle
|
||||
|
||||
if (isMe) {
|
||||
return _(msg`You`)
|
||||
}
|
||||
|
||||
if (!moderationOpts) return name
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
|
||||
return sanitizeDisplayName(name, moderation.ui('displayName'))
|
||||
}, [moderationOpts, profile])
|
||||
}, [_, isMe, moderationOpts, profile])
|
||||
|
||||
const [now] = React.useState(() => Date.now())
|
||||
const timeAgo = useGetTimeAgo()
|
||||
const createdAt = profile.createdAt as string | undefined
|
||||
const daysOld = React.useMemo(() => {
|
||||
if (!createdAt) return Infinity
|
||||
return differenceInSeconds(now, new Date(createdAt)) / 86400
|
||||
@@ -70,19 +82,31 @@ export function NewskieDialog({
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`New user info dialog`)}
|
||||
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<View style={[a.gap_md]}>
|
||||
<View style={[a.align_center]}>
|
||||
<Newskie
|
||||
width={64}
|
||||
height={64}
|
||||
fill="#FFC404"
|
||||
style={{marginTop: -10}}
|
||||
/>
|
||||
<Text style={[a.font_bold, a.text_xl, {marginTop: -10}]}>
|
||||
<Trans>Say hello!</Trans>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
height: 60,
|
||||
width: 64,
|
||||
},
|
||||
]}>
|
||||
<Newskie
|
||||
width={64}
|
||||
height={64}
|
||||
fill="#FFC404"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[a.font_bold, a.text_xl]}>
|
||||
{isMe ? (
|
||||
<Trans>Welcome, friend!</Trans>
|
||||
) : (
|
||||
<Trans>Say hello!</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[a.text_md, a.text_center, a.leading_tight]}>
|
||||
<Text style={[a.text_md, a.text_center, a.leading_snug]}>
|
||||
{profile.joinedViaStarterPack ? (
|
||||
<Trans>
|
||||
{profileName} joined Bluesky using a starter pack{' '}
|
||||
@@ -116,18 +140,23 @@ export function NewskieDialog({
|
||||
</View>
|
||||
</StarterPackCard.Link>
|
||||
) : null}
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
style={[a.mt_sm, isWeb && [a.self_center, {marginLeft: 'auto'}]]}
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
{isNative && (
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
style={[a.mt_sm]}
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {BSKY_LABELER_DID, ModerationCause} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export type CommonProps = {
|
||||
size?: 'sm' | 'lg'
|
||||
}
|
||||
|
||||
export function Row({
|
||||
children,
|
||||
style,
|
||||
size = 'sm',
|
||||
}: {children: React.ReactNode | React.ReactNode[]} & CommonProps &
|
||||
ViewStyleProp) {
|
||||
const styles = React.useMemo(() => {
|
||||
switch (size) {
|
||||
case 'lg':
|
||||
return [{gap: 5}]
|
||||
case 'sm':
|
||||
default:
|
||||
return [{gap: 3}]
|
||||
}
|
||||
}, [size])
|
||||
return (
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_xs, styles, style]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export type LabelProps = {
|
||||
cause: ModerationCause
|
||||
disableDetailsDialog?: boolean
|
||||
noBg?: boolean
|
||||
} & CommonProps
|
||||
|
||||
export function Label({
|
||||
cause,
|
||||
size = 'sm',
|
||||
disableDetailsDialog,
|
||||
noBg,
|
||||
}: LabelProps) {
|
||||
const t = useTheme()
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const desc = useModerationCauseDescription(cause)
|
||||
const isLabeler = Boolean(desc.sourceType && desc.sourceDid)
|
||||
const isBlueskyLabel =
|
||||
desc.sourceType === 'labeler' && desc.sourceDid === BSKY_LABELER_DID
|
||||
|
||||
const {outer, avi, text} = React.useMemo(() => {
|
||||
switch (size) {
|
||||
case 'lg': {
|
||||
return {
|
||||
outer: [
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
gap: 5,
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 5,
|
||||
},
|
||||
],
|
||||
avi: 16,
|
||||
text: [a.text_sm],
|
||||
}
|
||||
}
|
||||
case 'sm':
|
||||
default: {
|
||||
return {
|
||||
outer: [
|
||||
!noBg && t.atoms.bg_contrast_25,
|
||||
{
|
||||
gap: 3,
|
||||
paddingHorizontal: 3,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
],
|
||||
avi: 12,
|
||||
text: [a.text_xs],
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [t, size, noBg])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={disableDetailsDialog}
|
||||
label={desc.name}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
control.open()
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_full,
|
||||
outer,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
{isBlueskyLabel || !isLabeler ? (
|
||||
<desc.icon
|
||||
width={avi}
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
/>
|
||||
) : (
|
||||
<UserAvatar avatar={desc.sourceAvi} size={avi} />
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={[
|
||||
text,
|
||||
a.font_semibold,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_medium,
|
||||
{paddingRight: 3},
|
||||
]}>
|
||||
{desc.name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!disableDetailsDialog && (
|
||||
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function FollowsYou({size = 'sm'}: CommonProps) {
|
||||
const t = useTheme()
|
||||
|
||||
const variantStyles = React.useMemo(() => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
case 'lg':
|
||||
default:
|
||||
return [
|
||||
{
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 4,
|
||||
},
|
||||
]
|
||||
}
|
||||
}, [size])
|
||||
|
||||
return (
|
||||
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[a.text_xs, a.leading_tight]}>
|
||||
<Trans>Follows You</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
+360
-69
@@ -1,20 +1,33 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
moderateProfile,
|
||||
ModerationOpts,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name'
|
||||
import {LogEvents} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {useSession} from 'state/session'
|
||||
import {FollowButton} from 'view/com/profile/FollowButton'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {ProfileCardPills} from 'view/com/profile/ProfileCard'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Default({
|
||||
profile: profileUnshadowed,
|
||||
profile,
|
||||
moderationOpts,
|
||||
logContext = 'ProfileCard',
|
||||
}: {
|
||||
@@ -22,70 +35,348 @@ export function Default({
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const name = createSanitizedDisplayName(profile)
|
||||
const handle = `@${sanitizeHandle(profile.handle)}`
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
|
||||
return (
|
||||
<Wrapper did={profile.did}>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<UserAvatar
|
||||
size={42}
|
||||
avatar={profile.avatar}
|
||||
type={
|
||||
profile.associated?.labeler
|
||||
? 'labeler'
|
||||
: profile.associated?.feedgens
|
||||
? 'algo'
|
||||
: 'user'
|
||||
}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
<View style={[a.flex_1]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{handle}
|
||||
</Text>
|
||||
</View>
|
||||
{hasSession && profile.did !== currentAccount?.did && (
|
||||
<View style={[a.justify_center, {marginLeft: 'auto'}]}>
|
||||
<FollowButton profile={profile} logContext={logContext} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={[a.mb_xs]}>
|
||||
<ProfileCardPills
|
||||
followedBy={Boolean(profile.viewer?.followedBy)}
|
||||
moderation={moderation}
|
||||
/>
|
||||
</View>
|
||||
{profile.description && (
|
||||
<Text numberOfLines={3} style={[a.leading_snug]}>
|
||||
{profile.description}
|
||||
</Text>
|
||||
)}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function Wrapper({did, children}: {did: string; children: React.ReactNode}) {
|
||||
return (
|
||||
<Link
|
||||
to={{
|
||||
screen: 'Profile',
|
||||
params: {name: did},
|
||||
}}>
|
||||
<View style={[a.flex_1, a.gap_xs]}>{children}</View>
|
||||
<Link did={profile.did}>
|
||||
<Card
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext={logContext}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Card({
|
||||
profile,
|
||||
moderationOpts,
|
||||
logContext = 'ProfileCard',
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
}) {
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
|
||||
return (
|
||||
<Outer>
|
||||
<Header>
|
||||
<Avatar profile={profile} moderationOpts={moderationOpts} />
|
||||
<NameAndHandle profile={profile} moderationOpts={moderationOpts} />
|
||||
<FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext={logContext}
|
||||
/>
|
||||
</Header>
|
||||
|
||||
<ProfileCardPills
|
||||
followedBy={Boolean(profile.viewer?.followedBy)}
|
||||
moderation={moderation}
|
||||
/>
|
||||
|
||||
<Description profile={profile} />
|
||||
</Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactElement | React.ReactElement[]
|
||||
}) {
|
||||
return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View>
|
||||
}
|
||||
|
||||
export function Header({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactElement | React.ReactElement[]
|
||||
}) {
|
||||
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
|
||||
}
|
||||
|
||||
export function Link({
|
||||
did,
|
||||
children,
|
||||
style,
|
||||
...rest
|
||||
}: {did: string} & Omit<LinkProps, 'to'>) {
|
||||
return (
|
||||
<InternalLink
|
||||
to={{
|
||||
screen: 'Profile',
|
||||
params: {name: did},
|
||||
}}
|
||||
style={[a.flex_col, style]}
|
||||
{...rest}>
|
||||
{children}
|
||||
</InternalLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
|
||||
return (
|
||||
<UserAvatar
|
||||
size={42}
|
||||
avatar={profile.avatar}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AvatarPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
width: 42,
|
||||
height: 42,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NameAndHandle({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const name = sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{handle}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function NameAndHandlePlaceholder() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
width: '60%',
|
||||
height: 14,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
width: '40%',
|
||||
height: 10,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Description({
|
||||
profile: profileUnshadowed,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
}) {
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const {description} = profile
|
||||
const rt = React.useMemo(() => {
|
||||
if (!description) return
|
||||
const rt = new RichTextApi({text: description || ''})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
return rt
|
||||
}, [description])
|
||||
if (!rt) return null
|
||||
if (
|
||||
profile.viewer &&
|
||||
(profile.viewer.blockedBy ||
|
||||
profile.viewer.blocking ||
|
||||
profile.viewer.blockingByList)
|
||||
)
|
||||
return null
|
||||
return (
|
||||
<View style={[a.pt_xs]}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.leading_snug]}
|
||||
numberOfLines={3}
|
||||
disableLinks
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function DescriptionPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.gap_xs]}>
|
||||
<View
|
||||
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||
/>
|
||||
<View
|
||||
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
a.w_full,
|
||||
t.atoms.bg_contrast_50,
|
||||
{height: 12, width: 100},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export type FollowButtonProps = {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
moderationOpts: ModerationOpts
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:unfollow']['logContext']
|
||||
} & Partial<ButtonProps>
|
||||
|
||||
export function FollowButton(props: FollowButtonProps) {
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const isMe = props.profile.did === currentAccount?.did
|
||||
return hasSession && !isMe ? <FollowButtonInner {...props} /> : null
|
||||
}
|
||||
|
||||
export function FollowButtonInner({
|
||||
profile: profileUnshadowed,
|
||||
moderationOpts,
|
||||
logContext,
|
||||
...rest
|
||||
}: FollowButtonProps) {
|
||||
const {_} = useLingui()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
profile,
|
||||
logContext,
|
||||
)
|
||||
const isRound = Boolean(rest.shape && rest.shape === 'round')
|
||||
|
||||
const onPressFollow = async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await queueFollow()
|
||||
Toast.show(
|
||||
_(
|
||||
msg`Following ${sanitizeDisplayName(
|
||||
profile.displayName || profile.handle,
|
||||
moderation.ui('displayName'),
|
||||
)}`,
|
||||
),
|
||||
)
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
Toast.show(_(msg`An issue occurred, please try again.`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onPressUnfollow = async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await queueUnfollow()
|
||||
Toast.show(
|
||||
_(
|
||||
msg`No longer following ${sanitizeDisplayName(
|
||||
profile.displayName || profile.handle,
|
||||
moderation.ui('displayName'),
|
||||
)}`,
|
||||
),
|
||||
)
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
Toast.show(_(msg`An issue occurred, please try again.`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unfollowLabel = _(
|
||||
msg({
|
||||
message: 'Following',
|
||||
comment: 'User is following this account, click to unfollow',
|
||||
}),
|
||||
)
|
||||
const followLabel = _(
|
||||
msg({
|
||||
message: 'Follow',
|
||||
comment: 'User is not following this account, click to follow',
|
||||
}),
|
||||
)
|
||||
|
||||
if (!profile.viewer) return null
|
||||
if (
|
||||
profile.viewer.blockedBy ||
|
||||
profile.viewer.blocking ||
|
||||
profile.viewer.blockingByList
|
||||
)
|
||||
return null
|
||||
|
||||
return (
|
||||
<View>
|
||||
{profile.viewer.following ? (
|
||||
<Button
|
||||
label={unfollowLabel}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
{...rest}
|
||||
onPress={onPressUnfollow}>
|
||||
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
|
||||
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
label={followLabel}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
{...rest}
|
||||
onPress={onPressFollow}>
|
||||
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
|
||||
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ import {
|
||||
} from '#/components/KnownFollowers'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Pills from '#/components/Pills'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ProfileLabel} from '../moderation/ProfileHeaderAlerts'
|
||||
import {ProfileHoverCardProps} from './types'
|
||||
|
||||
const floatingMiddlewares = [
|
||||
@@ -476,8 +476,9 @@ function Inner({
|
||||
{isBlockedUser && (
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
|
||||
{moderation.ui('profileView').alerts.map(cause => (
|
||||
<ProfileLabel
|
||||
<Pills.Label
|
||||
key={getModerationCauseKey(cause)}
|
||||
size="lg"
|
||||
cause={cause}
|
||||
disableDetailsDialog
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
useProgressGuide,
|
||||
useProgressGuideControls,
|
||||
} from '#/state/shell/progress-guide'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ProgressGuideTask} from './Task'
|
||||
|
||||
export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const guide = useProgressGuide('like-10-and-follow-7')
|
||||
const {endProgressGuide} = useProgressGuideControls()
|
||||
|
||||
if (guide) {
|
||||
return (
|
||||
<View style={[a.flex_col, a.gap_md, style]}>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.font_semibold,
|
||||
a.text_sm,
|
||||
{textTransform: 'uppercase'},
|
||||
]}>
|
||||
<Trans>Getting started</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="tiny"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
label={_(msg`Dismiss getting started guide`)}
|
||||
onPress={endProgressGuide}>
|
||||
<ButtonIcon icon={Times} size="sm" />
|
||||
</Button>
|
||||
</View>
|
||||
<ProgressGuideTask
|
||||
current={guide.numLikes + 1}
|
||||
total={10 + 1}
|
||||
title={_(msg`Like 10 posts`)}
|
||||
subtitle={_(msg`Teach our algorithm what you like`)}
|
||||
/>
|
||||
<ProgressGuideTask
|
||||
current={guide.numFollows + 1}
|
||||
total={7 + 1}
|
||||
title={_(msg`Follow 7 accounts`)}
|
||||
subtitle={_(msg`Bluesky is better with friends!`)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import * as Progress from 'react-native-progress'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AnimatedCheck} from '../anim/AnimatedCheck'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export function ProgressGuideTask({
|
||||
current,
|
||||
total,
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
current: number
|
||||
total: number
|
||||
title: string
|
||||
subtitle?: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_sm, !subtitle && a.align_center]}>
|
||||
{current === total ? (
|
||||
<AnimatedCheck playOnMount fill={t.palette.primary_500} width={20} />
|
||||
) : (
|
||||
<Progress.Circle
|
||||
progress={current / total}
|
||||
color={t.palette.primary_400}
|
||||
size={20}
|
||||
thickness={3}
|
||||
borderWidth={0}
|
||||
unfilledColor={t.palette.contrast_50}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={[a.flex_col, a.gap_2xs, {marginTop: -2}]}>
|
||||
<Text style={[a.text_sm, a.font_semibold, a.leading_tight]}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_tight]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {Pressable, useWindowDimensions, View} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {AnimatedCheck, AnimatedCheckRef} from '../anim/AnimatedCheck'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export interface ProgressGuideToastRef {
|
||||
open(): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
export interface ProgressGuideToastProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
visibleDuration?: number // default 5s
|
||||
}
|
||||
|
||||
export const ProgressGuideToast = React.forwardRef<
|
||||
ProgressGuideToastRef,
|
||||
ProgressGuideToastProps
|
||||
>(function ProgressGuideToast({title, subtitle, visibleDuration}, ref) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const translateY = useSharedValue(0)
|
||||
const opacity = useSharedValue(0)
|
||||
const animatedCheckRef = React.useRef<AnimatedCheckRef | null>(null)
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | undefined>()
|
||||
const winDim = useWindowDimensions()
|
||||
|
||||
/**
|
||||
* Methods
|
||||
*/
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
// clear the timeout, in case this was called imperatively
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = undefined
|
||||
}
|
||||
|
||||
// animate the opacity then set isOpen to false when done
|
||||
const setIsntOpen = () => setIsOpen(false)
|
||||
opacity.value = withTiming(
|
||||
0,
|
||||
{
|
||||
duration: 400,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
},
|
||||
() => runOnJS(setIsntOpen)(),
|
||||
)
|
||||
}, [setIsOpen, opacity])
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
// set isOpen=true to render
|
||||
setIsOpen(true)
|
||||
|
||||
// animate the vertical translation, the opacity, and the checkmark
|
||||
const playCheckmark = () => animatedCheckRef.current?.play()
|
||||
opacity.value = 0
|
||||
opacity.value = withTiming(
|
||||
1,
|
||||
{
|
||||
duration: 100,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
},
|
||||
() => runOnJS(playCheckmark)(),
|
||||
)
|
||||
translateY.value = 0
|
||||
translateY.value = withTiming(insets.top + 10, {
|
||||
duration: 500,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
|
||||
// start the countdown timer to autoclose
|
||||
timeoutRef.current = setTimeout(close, visibleDuration || 5e3)
|
||||
}, [setIsOpen, translateY, opacity, insets, close, visibleDuration])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[open, close],
|
||||
)
|
||||
|
||||
const containerStyle = React.useMemo(() => {
|
||||
let left = 10
|
||||
let right = 10
|
||||
if (isWeb && winDim.width > 400) {
|
||||
left = right = (winDim.width - 380) / 2
|
||||
}
|
||||
return {
|
||||
position: isWeb ? 'fixed' : 'absolute',
|
||||
top: 0,
|
||||
left,
|
||||
right,
|
||||
}
|
||||
}, [winDim.width])
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{translateY: translateY.value}],
|
||||
opacity: opacity.value,
|
||||
}))
|
||||
|
||||
return (
|
||||
isOpen && (
|
||||
<Portal>
|
||||
<Animated.View
|
||||
style={[
|
||||
// @ts-ignore position: fixed is web only
|
||||
containerStyle,
|
||||
animatedStyle,
|
||||
]}>
|
||||
<Pressable
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_md,
|
||||
a.border,
|
||||
t.atoms.border_contrast_high,
|
||||
a.rounded_md,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
a.shadow_sm,
|
||||
{
|
||||
shadowRadius: 8,
|
||||
shadowOpacity: 0.1,
|
||||
shadowOffset: {width: 0, height: 2},
|
||||
elevation: 8,
|
||||
},
|
||||
]}
|
||||
onPress={close}
|
||||
accessibilityLabel={_(msg`Tap to dismiss`)}
|
||||
accessibilityHint="">
|
||||
<AnimatedCheck
|
||||
fill={t.palette.primary_500}
|
||||
ref={animatedCheckRef}
|
||||
/>
|
||||
<View>
|
||||
<Text style={[a.text_md, a.font_semibold]}>{title}</Text>
|
||||
{subtitle && (
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</Portal>
|
||||
)
|
||||
)
|
||||
})
|
||||
+14
-12
@@ -17,6 +17,19 @@ import {Text, TextProps} from '#/components/Typography'
|
||||
|
||||
const WORD_WRAP = {wordWrap: 1}
|
||||
|
||||
export type RichTextProps = TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
value: RichTextAPI | string
|
||||
testID?: string
|
||||
numberOfLines?: number
|
||||
disableLinks?: boolean
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
interactiveStyle?: TextStyle
|
||||
emojiMultiplier?: number
|
||||
}
|
||||
|
||||
export function RichText({
|
||||
testID,
|
||||
value,
|
||||
@@ -29,18 +42,7 @@ export function RichText({
|
||||
onLinkPress,
|
||||
interactiveStyle,
|
||||
emojiMultiplier = 1.85,
|
||||
}: TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
value: RichTextAPI | string
|
||||
testID?: string
|
||||
numberOfLines?: number
|
||||
disableLinks?: boolean
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
interactiveStyle?: TextStyle
|
||||
emojiMultiplier?: number
|
||||
}) {
|
||||
}: RichTextProps) {
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
|
||||
@@ -45,7 +45,7 @@ export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||
(isWeb || index !== 0) && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<FeedCard.Default type="feed" view={item} />
|
||||
<FeedCard.Default view={item} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {Feed} from 'view/com/posts/Feed'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import {ListRef} from 'view/com/util/List'
|
||||
import {SectionRef} from '#/screens/Profile/Sections/types'
|
||||
|
||||
interface ProfilesListProps {
|
||||
listUri: string
|
||||
headerHeight: number
|
||||
scrollElRef: ListRef
|
||||
}
|
||||
|
||||
export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||
function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) {
|
||||
const feed: FeedDescriptor = `list|${listUri}|as_following`
|
||||
const {_} = useLingui()
|
||||
|
||||
const onScrollToTop = useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({
|
||||
animated: isNative,
|
||||
offset: -headerHeight,
|
||||
})
|
||||
}, [scrollElRef, headerHeight])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToTop: onScrollToTop,
|
||||
}))
|
||||
|
||||
const renderPostsEmpty = useCallback(() => {
|
||||
return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} />
|
||||
}, [_])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Feed
|
||||
feed={feed}
|
||||
pollInterval={60e3}
|
||||
scrollElRef={scrollElRef}
|
||||
renderEmptyState={renderPostsEmpty}
|
||||
headerOffset={headerHeight}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -9,11 +9,14 @@ import {
|
||||
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
|
||||
|
||||
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||
import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {useListMembersQuery} from 'state/queries/list-members'
|
||||
import {useSession} from 'state/session'
|
||||
import {List, ListRef} from 'view/com/util/List'
|
||||
import {SectionRef} from '#/screens/Profile/Sections/types'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {Default as ProfileCard} from '#/components/ProfileCard'
|
||||
|
||||
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
|
||||
@@ -32,21 +35,21 @@ interface ProfilesListProps {
|
||||
|
||||
export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||
function ProfilesListImpl(
|
||||
{listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef},
|
||||
{listUri, moderationOpts, headerHeight, scrollElRef},
|
||||
ref,
|
||||
) {
|
||||
const t = useTheme()
|
||||
const [initialHeaderHeight] = React.useState(headerHeight)
|
||||
const bottomBarOffset = useBottomBarOffset(20)
|
||||
const {currentAccount} = useSession()
|
||||
const {data, refetch, isError} = useListMembersQuery(listUri, 50)
|
||||
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
|
||||
const {data, refetch} = listMembersQuery
|
||||
|
||||
// The server returns these sorted by descending creation date, so we want to invert
|
||||
const profiles = data?.pages
|
||||
.flatMap(p => p.items.map(i => i.subject))
|
||||
.filter(p => !isBlockedOrBlocking(p) && !p.associated?.labeler)
|
||||
.reverse()
|
||||
const isOwn = new AtUri(listUri).host === currentAccount?.did
|
||||
|
||||
@@ -94,7 +97,19 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||
)
|
||||
}
|
||||
|
||||
if (listMembersQuery)
|
||||
if (!data) {
|
||||
return (
|
||||
<View style={{marginTop: headerHeight, marginBottom: bottomBarOffset}}>
|
||||
<ListMaybePlaceholder
|
||||
isLoading={true}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (data)
|
||||
return (
|
||||
<List
|
||||
data={getSortedProfiles()}
|
||||
|
||||
@@ -232,11 +232,13 @@ function Empty() {
|
||||
t.atoms.text_contrast_medium,
|
||||
{color: 'white'},
|
||||
]}>
|
||||
You haven't created a starter pack yet!
|
||||
<Trans>You haven't created a starter pack yet!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, {color: 'white'}]}>
|
||||
Starter packs let you easily share your favorite feeds and people with
|
||||
your friends.
|
||||
<Trans>
|
||||
Starter packs let you easily share your favorite feeds and people
|
||||
with your friends.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.flex_row, a.gap_md, {marginLeft: 'auto'}]}>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import ViewShot from 'react-native-view-shot'
|
||||
import * as FS from 'expo-file-system'
|
||||
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
|
||||
import {createAssetAsync} from 'expo-media-library'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {saveImageToMediaLibrary} from 'lib/media/manip'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
@@ -65,13 +63,9 @@ export function QrCodeDialog({
|
||||
return
|
||||
}
|
||||
|
||||
const filename = `${FS.documentDirectory}/${nanoid(12)}.png`
|
||||
|
||||
// Incase of a FS failure, don't crash the app
|
||||
try {
|
||||
await FS.copyAsync({from: uri, to: filename})
|
||||
await saveImageToMediaLibrary({uri: filename})
|
||||
await FS.deleteAsync(filename)
|
||||
await createAssetAsync(`file://${uri}`)
|
||||
} catch (e: unknown) {
|
||||
Toast.show(_(msg`An error occurred while saving the QR code!`))
|
||||
logger.error('Failed to save QR code', {error: e})
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import * as FS from 'expo-file-system'
|
||||
import {Image} from 'expo-image'
|
||||
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
|
||||
import {AppBskyGraphDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
@@ -72,19 +70,8 @@ function ShareDialogInner({
|
||||
return
|
||||
}
|
||||
|
||||
const cachePath = await Image.getCachePathAsync(imageUrl)
|
||||
const filename = `${FS.documentDirectory}/${nanoid(12)}.png`
|
||||
|
||||
if (!cachePath) {
|
||||
Toast.show(_(msg`An error occurred while saving the image.`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await FS.copyAsync({from: cachePath, to: filename})
|
||||
await saveImageToMediaLibrary({uri: filename})
|
||||
await FS.deleteAsync(filename)
|
||||
|
||||
await saveImageToMediaLibrary({uri: imageUrl})
|
||||
Toast.show(_(msg`Image saved to your camera roll!`))
|
||||
control.close()
|
||||
} catch (e: unknown) {
|
||||
@@ -133,18 +120,18 @@ function ShareDialogInner({
|
||||
isWeb && [a.gap_sm, a.flex_row_reverse, {marginLeft: 'auto'}],
|
||||
]}>
|
||||
<Button
|
||||
label="Share link"
|
||||
label={isWeb ? _(msg`Copy link`) : _(msg`Share link`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
style={[isWeb && a.self_center]}
|
||||
onPress={onShareLink}>
|
||||
<ButtonText>
|
||||
{isWeb ? <Trans>Copy Link</Trans> : <Trans>Share Link</Trans>}
|
||||
{isWeb ? <Trans>Copy Link</Trans> : <Trans>Share link</Trans>}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label="Create QR code"
|
||||
label={_(msg`Share QR code`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
@@ -155,7 +142,7 @@ function ShareDialogInner({
|
||||
})
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Create QR code</Trans>
|
||||
<Trans>Share QR code</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{isNative && (
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||
import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
|
||||
import {precacheResolvedUri} from 'state/queries/resolve-uri'
|
||||
import {precacheStarterPack} from 'state/queries/starter-packs'
|
||||
import {useSession} from 'state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {StarterPack} from '#/components/icons/StarterPack'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {BaseLink} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
|
||||
@@ -88,10 +93,13 @@ export function Card({
|
||||
export function Link({
|
||||
starterPack,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
starterPack: StarterPackViewBasic
|
||||
} & Omit<LinkProps, 'to'>) {
|
||||
onPress?: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const {record} = starterPack
|
||||
const {rkey, handleOrDid} = React.useMemo(() => {
|
||||
const rkey = new AtUri(starterPack.uri).rkey
|
||||
@@ -104,14 +112,46 @@ export function Link({
|
||||
}
|
||||
|
||||
return (
|
||||
<InternalLink
|
||||
label={record.name}
|
||||
{...rest}
|
||||
to={{
|
||||
screen: 'StarterPack',
|
||||
params: {name: handleOrDid, rkey},
|
||||
<BaseLink
|
||||
action="push"
|
||||
to={`/starter-pack/${handleOrDid}/${rkey}`}
|
||||
label={_(msg`Navigate to ${record.name}`)}
|
||||
onPress={() => {
|
||||
precacheResolvedUri(
|
||||
queryClient,
|
||||
starterPack.creator.handle,
|
||||
starterPack.creator.did,
|
||||
)
|
||||
precacheStarterPack(queryClient, starterPack)
|
||||
}}>
|
||||
{children}
|
||||
</InternalLink>
|
||||
</BaseLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function Embed({starterPack}: {starterPack: StarterPackViewBasic}) {
|
||||
const t = useTheme()
|
||||
const imageUri = getStarterPackOgCard(starterPack)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.mt_xs,
|
||||
a.border,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Link starterPack={starterPack}>
|
||||
<Image
|
||||
source={imageUri}
|
||||
style={[a.w_full, {aspectRatio: 1.91}]}
|
||||
accessibilityIgnoresInvertColors={true}
|
||||
/>
|
||||
<View style={[a.px_sm, a.py_md]}>
|
||||
<Card starterPack={starterPack} />
|
||||
</View>
|
||||
</Link>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export function WizardEditListDialog({
|
||||
state.currentStep === 'Profiles' ? (
|
||||
<WizardProfileCard
|
||||
profile={item}
|
||||
btnType="remove"
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
moderationOpts={moderationOpts}
|
||||
@@ -65,6 +66,7 @@ export function WizardEditListDialog({
|
||||
) : (
|
||||
<WizardFeedCard
|
||||
generator={item}
|
||||
btnType="remove"
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
moderationOpts={moderationOpts}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ModerationUI,
|
||||
} from '@atproto/api'
|
||||
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {DISCOVER_FEED_URI} from 'lib/constants'
|
||||
@@ -19,12 +19,14 @@ import {useSession} from 'state/session'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Checkbox} from '#/components/forms/Toggle'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
function WizardListCard({
|
||||
type,
|
||||
btnType,
|
||||
displayName,
|
||||
subtitle,
|
||||
onPress,
|
||||
@@ -34,6 +36,7 @@ function WizardListCard({
|
||||
moderationUi,
|
||||
}: {
|
||||
type: 'user' | 'algo'
|
||||
btnType: 'checkbox' | 'remove'
|
||||
profile?: AppBskyActorDefs.ProfileViewBasic
|
||||
feed?: AppBskyFeedDefs.GeneratorView
|
||||
displayName: string
|
||||
@@ -56,7 +59,7 @@ function WizardListCard({
|
||||
: _(msg`Add ${displayName} to starter pack`)
|
||||
}
|
||||
value={included}
|
||||
disabled={disabled}
|
||||
disabled={btnType === 'remove' || disabled}
|
||||
onChange={onPress}
|
||||
style={[
|
||||
a.flex_row,
|
||||
@@ -85,17 +88,33 @@ function WizardListCard({
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
<Checkbox />
|
||||
{btnType === 'checkbox' ? (
|
||||
<Checkbox />
|
||||
) : !disabled ? (
|
||||
<Button
|
||||
label={_(msg`Remove`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="xsmall"
|
||||
style={[a.self_center, {marginLeft: 'auto'}]}
|
||||
onPress={onPress}>
|
||||
<ButtonText>
|
||||
<Trans>Remove</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : null}
|
||||
</Toggle.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export function WizardProfileCard({
|
||||
btnType,
|
||||
state,
|
||||
dispatch,
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
btnType: 'checkbox' | 'remove'
|
||||
state: WizardState
|
||||
dispatch: (action: WizardAction) => void
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
@@ -127,6 +146,7 @@ export function WizardProfileCard({
|
||||
return (
|
||||
<WizardListCard
|
||||
type="user"
|
||||
btnType={btnType}
|
||||
displayName={displayName}
|
||||
subtitle={`@${sanitizeHandle(profile.handle)}`}
|
||||
onPress={onPress}
|
||||
@@ -139,11 +159,13 @@ export function WizardProfileCard({
|
||||
}
|
||||
|
||||
export function WizardFeedCard({
|
||||
btnType,
|
||||
generator,
|
||||
state,
|
||||
dispatch,
|
||||
moderationOpts,
|
||||
}: {
|
||||
btnType: 'checkbox' | 'remove'
|
||||
generator: GeneratorView
|
||||
state: WizardState
|
||||
dispatch: (action: WizardAction) => void
|
||||
@@ -170,6 +192,7 @@ export function WizardFeedCard({
|
||||
return (
|
||||
<WizardListCard
|
||||
type="algo"
|
||||
btnType={btnType}
|
||||
displayName={sanitizeDisplayName(generator.displayName)}
|
||||
subtitle={`Feed by @${sanitizeHandle(generator.creator.handle)}`}
|
||||
onPress={onPress}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {HITSLOP_10} from '#/lib/constants'
|
||||
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
|
||||
import {
|
||||
ThreadgateSetting,
|
||||
@@ -33,7 +32,9 @@ import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/componen
|
||||
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
|
||||
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {TextLink} from '../util/Link'
|
||||
import {TextLink} from '../view/com/util/Link'
|
||||
import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
|
||||
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
|
||||
|
||||
interface WhoCanReplyProps {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
@@ -41,15 +42,19 @@ interface WhoCanReplyProps {
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
export function WhoCanReplyInline({
|
||||
post,
|
||||
isThreadAuthor,
|
||||
style,
|
||||
}: WhoCanReplyProps) {
|
||||
export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const infoDialogControl = useDialogControl()
|
||||
const {settings, isRootPost, onPressEdit} = useWhoCanReply(post)
|
||||
const editDialogControl = useDialogControl()
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const settings = React.useMemo(
|
||||
() => threadgateViewToSettings(post.threadgate),
|
||||
[post],
|
||||
)
|
||||
const isRootPost = !('reply' in post.record)
|
||||
|
||||
if (!isRootPost) {
|
||||
return null
|
||||
@@ -66,6 +71,55 @@ export function WhoCanReplyInline({
|
||||
? _(msg`Replies disabled`)
|
||||
: _(msg`Some people can reply`)
|
||||
|
||||
const onPressEdit = () => {
|
||||
if (isNative && Keyboard.isVisible()) {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
if (isThreadAuthor) {
|
||||
editDialogControl.open()
|
||||
} else {
|
||||
infoDialogControl.open()
|
||||
}
|
||||
}
|
||||
|
||||
const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
|
||||
if (JSON.stringify(settings) === JSON.stringify(newSettings)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (newSettings.length) {
|
||||
await createThreadgate(agent, post.uri, newSettings)
|
||||
} else {
|
||||
await agent.api.com.atproto.repo.deleteRecord({
|
||||
repo: agent.session!.did,
|
||||
collection: 'app.bsky.feed.threadgate',
|
||||
rkey: new AtUri(post.uri).rkey,
|
||||
})
|
||||
}
|
||||
await whenAppViewReady(agent, post.uri, res => {
|
||||
const thread = res.data.thread
|
||||
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
|
||||
const fetchedSettings = threadgateViewToSettings(
|
||||
thread.post.threadgate,
|
||||
)
|
||||
return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
|
||||
}
|
||||
return false
|
||||
})
|
||||
Toast.show(_(msg`Thread settings updated`))
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [POST_THREAD_RQKEY_ROOT],
|
||||
})
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`There was an issue. Please check your internet connection and try again.`,
|
||||
),
|
||||
)
|
||||
logger.error('Failed to edit threadgate', {message: err})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
@@ -90,73 +144,24 @@ export function WhoCanReplyInline({
|
||||
]}>
|
||||
{description}
|
||||
</Text>
|
||||
{isThreadAuthor && (
|
||||
<PencilLine width={12} fill={t.palette.primary_500} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
<InfoDialog control={infoDialogControl} post={post} settings={settings} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function WhoCanReplyBlock({
|
||||
post,
|
||||
isThreadAuthor,
|
||||
style,
|
||||
}: WhoCanReplyProps) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const infoDialogControl = useDialogControl()
|
||||
const {settings, isRootPost, onPressEdit} = useWhoCanReply(post)
|
||||
|
||||
if (!isRootPost) {
|
||||
return null
|
||||
}
|
||||
if (!settings.length && !isThreadAuthor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isEverybody = settings.length === 0
|
||||
const isNobody = !!settings.find(gate => gate.type === 'nobody')
|
||||
const description = isEverybody
|
||||
? _(msg`Everybody can reply`)
|
||||
: isNobody
|
||||
? _(msg`Replies on this thread are disabled`)
|
||||
: _(msg`Some people can reply`)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={
|
||||
isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
|
||||
}
|
||||
onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open}
|
||||
hitSlop={HITSLOP_10}>
|
||||
{({hovered}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.py_sm,
|
||||
a.pr_lg,
|
||||
style,
|
||||
]}>
|
||||
<View style={[{paddingLeft: 25, paddingRight: 18}]}>
|
||||
<Icon color={t.palette.contrast_300} settings={settings} />
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_medium,
|
||||
hovered && a.underline,
|
||||
]}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
<InfoDialog control={infoDialogControl} post={post} settings={settings} />
|
||||
<WhoCanReplyDialog
|
||||
control={infoDialogControl}
|
||||
post={post}
|
||||
settings={settings}
|
||||
/>
|
||||
{isThreadAuthor && (
|
||||
<ThreadgateEditorDialog
|
||||
control={editDialogControl}
|
||||
threadgate={settings}
|
||||
onConfirm={onEditConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -176,7 +181,7 @@ function Icon({
|
||||
return <IconComponent fill={color} width={width} />
|
||||
}
|
||||
|
||||
function InfoDialog({
|
||||
function WhoCanReplyDialog({
|
||||
control,
|
||||
post,
|
||||
settings,
|
||||
@@ -188,12 +193,12 @@ function InfoDialog({
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<InfoDialogInner post={post} settings={settings} />
|
||||
<WhoCanReplyDialogInner post={post} settings={settings} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoDialogInner({
|
||||
function WhoCanReplyDialogInner({
|
||||
post,
|
||||
settings,
|
||||
}: {
|
||||
@@ -315,64 +320,6 @@ function Separator({i, length}: {i: number; length: number}) {
|
||||
return <>, </>
|
||||
}
|
||||
|
||||
function useWhoCanReply(post: AppBskyFeedDefs.PostView) {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const {openModal} = useModalControls()
|
||||
|
||||
const settings = React.useMemo(
|
||||
() => threadgateViewToSettings(post.threadgate),
|
||||
[post],
|
||||
)
|
||||
const isRootPost = !('reply' in post.record)
|
||||
|
||||
const onPressEdit = () => {
|
||||
if (isNative && Keyboard.isVisible()) {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
openModal({
|
||||
name: 'threadgate',
|
||||
settings,
|
||||
async onConfirm(newSettings: ThreadgateSetting[]) {
|
||||
try {
|
||||
if (newSettings.length) {
|
||||
await createThreadgate(agent, post.uri, newSettings)
|
||||
} else {
|
||||
await agent.api.com.atproto.repo.deleteRecord({
|
||||
repo: agent.session!.did,
|
||||
collection: 'app.bsky.feed.threadgate',
|
||||
rkey: new AtUri(post.uri).rkey,
|
||||
})
|
||||
}
|
||||
await whenAppViewReady(agent, post.uri, res => {
|
||||
const thread = res.data.thread
|
||||
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
|
||||
const fetchedSettings = threadgateViewToSettings(
|
||||
thread.post.threadgate,
|
||||
)
|
||||
return (
|
||||
JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
|
||||
)
|
||||
}
|
||||
return false
|
||||
})
|
||||
Toast.show('Thread settings updated')
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [POST_THREAD_RQKEY_ROOT],
|
||||
})
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
'There was an issue. Please check your internet connection and try again.',
|
||||
)
|
||||
logger.error('Failed to edit threadgate', {message: err})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {settings, isRootPost, onPressEdit}
|
||||
}
|
||||
|
||||
async function whenAppViewReady(
|
||||
agent: BskyAgent,
|
||||
uri: string,
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react'
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedProps,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import Svg, {Circle, Path} from 'react-native-svg'
|
||||
|
||||
import {Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
|
||||
const AnimatedPath = Animated.createAnimatedComponent(Path)
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle)
|
||||
|
||||
const PATH = 'M14.1 27.2l7.1 7.2 16.7-16.8'
|
||||
|
||||
export interface AnimatedCheckRef {
|
||||
play(cb?: () => void): void
|
||||
}
|
||||
|
||||
export interface AnimatedCheckProps extends Props {
|
||||
playOnMount?: boolean
|
||||
}
|
||||
|
||||
export const AnimatedCheck = React.forwardRef<
|
||||
AnimatedCheckRef,
|
||||
AnimatedCheckProps
|
||||
>(function AnimatedCheck({playOnMount, ...props}, ref) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
const circleAnim = useSharedValue(0)
|
||||
const checkAnim = useSharedValue(0)
|
||||
|
||||
const circleAnimatedProps = useAnimatedProps(() => ({
|
||||
strokeDashoffset: 166 - circleAnim.value * 166,
|
||||
}))
|
||||
const checkAnimatedProps = useAnimatedProps(() => ({
|
||||
strokeDashoffset: 48 - 48 * checkAnim.value,
|
||||
}))
|
||||
|
||||
const play = React.useCallback(
|
||||
(cb?: () => void) => {
|
||||
circleAnim.value = 0
|
||||
checkAnim.value = 0
|
||||
|
||||
circleAnim.value = withTiming(1, {duration: 500, easing: Easing.linear})
|
||||
checkAnim.value = withDelay(
|
||||
500,
|
||||
withTiming(1, {duration: 300, easing: Easing.linear}, cb),
|
||||
)
|
||||
},
|
||||
[circleAnim, checkAnim],
|
||||
)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
play,
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
if (playOnMount) {
|
||||
play()
|
||||
}
|
||||
}, [play, playOnMount])
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
viewBox="0 0 52 52"
|
||||
width={size}
|
||||
height={size}
|
||||
style={style}>
|
||||
<AnimatedCircle
|
||||
animatedProps={circleAnimatedProps}
|
||||
cx="26"
|
||||
cy="26"
|
||||
r="24"
|
||||
fill="none"
|
||||
stroke={fill}
|
||||
strokeWidth={4}
|
||||
strokeDasharray={166}
|
||||
/>
|
||||
<AnimatedPath
|
||||
animatedProps={checkAnimatedProps}
|
||||
stroke={fill}
|
||||
d={PATH}
|
||||
strokeWidth={4}
|
||||
strokeDasharray={48}
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
})
|
||||
@@ -113,6 +113,7 @@ export function EmbedConsentDialog({
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import isEqual from 'lodash.isequal'
|
||||
|
||||
import {useMyListsQuery} from '#/state/queries/my-lists'
|
||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
interface ThreadgateEditorDialogProps {
|
||||
control: Dialog.DialogControlProps
|
||||
threadgate: ThreadgateSetting[]
|
||||
onChange?: (v: ThreadgateSetting[]) => void
|
||||
onConfirm?: (v: ThreadgateSetting[]) => void
|
||||
}
|
||||
|
||||
export function ThreadgateEditorDialog({
|
||||
control,
|
||||
threadgate,
|
||||
onChange,
|
||||
onConfirm,
|
||||
}: ThreadgateEditorDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<DialogContent
|
||||
seedThreadgate={threadgate}
|
||||
onChange={onChange}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
seedThreadgate,
|
||||
onChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
seedThreadgate: ThreadgateSetting[]
|
||||
onChange?: (v: ThreadgateSetting[]) => void
|
||||
onConfirm?: (v: ThreadgateSetting[]) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
const {data: lists} = useMyListsQuery('curate')
|
||||
const [draft, setDraft] = React.useState(seedThreadgate)
|
||||
|
||||
const [prevSeedThreadgate, setPrevSeedThreadgate] =
|
||||
React.useState(seedThreadgate)
|
||||
if (seedThreadgate !== prevSeedThreadgate) {
|
||||
// New data flowed from above (e.g. due to update coming through).
|
||||
setPrevSeedThreadgate(seedThreadgate)
|
||||
setDraft(seedThreadgate) // Reset draft.
|
||||
}
|
||||
|
||||
function updateThreadgate(nextThreadgate: ThreadgateSetting[]) {
|
||||
setDraft(nextThreadgate)
|
||||
onChange?.(nextThreadgate)
|
||||
}
|
||||
|
||||
const onPressEverybody = () => {
|
||||
updateThreadgate([])
|
||||
}
|
||||
|
||||
const onPressNobody = () => {
|
||||
updateThreadgate([{type: 'nobody'}])
|
||||
}
|
||||
|
||||
const onPressAudience = (setting: ThreadgateSetting) => {
|
||||
// remove nobody
|
||||
let newSelected = draft.filter(v => v.type !== 'nobody')
|
||||
// toggle
|
||||
const i = newSelected.findIndex(v => isEqual(v, setting))
|
||||
if (i === -1) {
|
||||
newSelected.push(setting)
|
||||
} else {
|
||||
newSelected.splice(i, 1)
|
||||
}
|
||||
updateThreadgate(newSelected)
|
||||
}
|
||||
|
||||
const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`)
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Choose who can reply`)}
|
||||
style={[{maxWidth: 500}, a.w_full]}>
|
||||
<View style={[a.flex_1, a.gap_md]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Choose who can reply</Trans>
|
||||
</Text>
|
||||
<Text style={a.mt_xs}>
|
||||
<Trans>Either choose "Everybody" or "Nobody"</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<Selectable
|
||||
label={_(msg`Everybody`)}
|
||||
isSelected={draft.length === 0}
|
||||
onPress={onPressEverybody}
|
||||
style={{flex: 1}}
|
||||
/>
|
||||
<Selectable
|
||||
label={_(msg`Nobody`)}
|
||||
isSelected={!!draft.find(v => v.type === 'nobody')}
|
||||
onPress={onPressNobody}
|
||||
style={{flex: 1}}
|
||||
/>
|
||||
</View>
|
||||
<Text style={a.mt_md}>
|
||||
<Trans>Or combine these options:</Trans>
|
||||
</Text>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Selectable
|
||||
label={_(msg`Mentioned users`)}
|
||||
isSelected={!!draft.find(v => v.type === 'mention')}
|
||||
onPress={() => onPressAudience({type: 'mention'})}
|
||||
/>
|
||||
<Selectable
|
||||
label={_(msg`Followed users`)}
|
||||
isSelected={!!draft.find(v => v.type === 'following')}
|
||||
onPress={() => onPressAudience({type: 'following'})}
|
||||
/>
|
||||
{lists && lists.length > 0
|
||||
? lists.map(list => (
|
||||
<Selectable
|
||||
key={list.uri}
|
||||
label={_(msg`Users in "${list.name}"`)}
|
||||
isSelected={
|
||||
!!draft.find(v => v.type === 'list' && v.list === list.uri)
|
||||
}
|
||||
onPress={() =>
|
||||
onPressAudience({type: 'list', list: list.uri})
|
||||
}
|
||||
/>
|
||||
))
|
||||
: // No loading states to avoid jumps for the common case (no lists)
|
||||
null}
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
label={doneLabel}
|
||||
onPress={() => {
|
||||
control.close()
|
||||
onConfirm?.(draft)
|
||||
}}
|
||||
onAccessibilityEscape={control.close}
|
||||
color="primary"
|
||||
size="medium"
|
||||
variant="solid"
|
||||
style={a.mt_xl}>
|
||||
<ButtonText>{doneLabel}</ButtonText>
|
||||
</Button>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
function Selectable({
|
||||
label,
|
||||
isSelected,
|
||||
onPress,
|
||||
style,
|
||||
}: {
|
||||
label: string
|
||||
isSelected: boolean
|
||||
onPress: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Button
|
||||
onPress={onPress}
|
||||
label={label}
|
||||
accessibilityHint="Select this option"
|
||||
accessibilityRole="checkbox"
|
||||
aria-checked={isSelected}
|
||||
accessibilityState={{
|
||||
checked: isSelected,
|
||||
}}
|
||||
style={a.flex_1}>
|
||||
{({hovered, focused}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.rounded_sm,
|
||||
a.p_md,
|
||||
{height: 40}, // for consistency with checkmark icon visible or not
|
||||
t.atoms.bg_contrast_50,
|
||||
(hovered || focused) && t.atoms.bg_contrast_100,
|
||||
isSelected && {
|
||||
backgroundColor:
|
||||
t.name === 'light'
|
||||
? t.palette.primary_50
|
||||
: t.palette.primary_975,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
|
||||
{label}
|
||||
</Text>
|
||||
{isSelected ? (
|
||||
<Check size="sm" fill={t.palette.primary_500} />
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -214,7 +214,7 @@ function HeaderReady({
|
||||
]}>
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
size="large"
|
||||
size="lg"
|
||||
style={[a.pt_xs]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -8,6 +8,10 @@ export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
|
||||
})
|
||||
|
||||
export const ArrowRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M21 12a1 1 0 0 1-.293.707l-6 6a1 1 0 0 1-1.414-1.414L17.586 13H4a1 1 0 1 1 0-2h13.586l-4.293-4.293a1 1 0 0 1 1.414-1.414l6 6A1 1 0 0 1 21 12Z',
|
||||
})
|
||||
|
||||
export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const VideoClip_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z',
|
||||
})
|
||||
@@ -165,9 +165,7 @@ export function ContentHider({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
outer: {},
|
||||
cover: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {BSKY_LABELER_DID, ModerationCause, ModerationUI} from '@atproto/api'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
|
||||
import {getModerationCauseKey} from '#/lib/moderation'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Pills from '#/components/Pills'
|
||||
|
||||
export function PostAlerts({
|
||||
modui,
|
||||
size,
|
||||
size = 'sm',
|
||||
style,
|
||||
}: {
|
||||
modui: ModerationUI
|
||||
size?: 'medium' | 'large'
|
||||
size?: Pills.CommonProps['size']
|
||||
includeMute?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
@@ -28,90 +20,23 @@ export function PostAlerts({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_col, a.gap_xs, style]}>
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
|
||||
{modui.alerts.map(cause => (
|
||||
<PostLabel
|
||||
key={getModerationCauseKey(cause)}
|
||||
cause={cause}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
{modui.informs.map(cause => (
|
||||
<PostLabel
|
||||
key={getModerationCauseKey(cause)}
|
||||
cause={cause}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PostLabel({
|
||||
cause,
|
||||
size,
|
||||
}: {
|
||||
cause: ModerationCause
|
||||
size?: 'medium' | 'large'
|
||||
}) {
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const desc = useModerationCauseDescription(cause)
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={desc.name}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
control.open()
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
a.rounded_sm,
|
||||
hovered || pressed
|
||||
? size === 'large'
|
||||
? t.atoms.bg_contrast_50
|
||||
: t.atoms.bg_contrast_25
|
||||
: size === 'large'
|
||||
? t.atoms.bg_contrast_25
|
||||
: undefined,
|
||||
size === 'large'
|
||||
? {paddingLeft: 4, paddingRight: 6, paddingVertical: 2}
|
||||
: {paddingRight: 4, paddingVertical: 1},
|
||||
]}>
|
||||
{desc.sourceType === 'labeler' &&
|
||||
desc.sourceDid !== BSKY_LABELER_DID ? (
|
||||
<UserAvatar
|
||||
avatar={desc.sourceAvi}
|
||||
size={size === 'large' ? 16 : 12}
|
||||
type="labeler"
|
||||
shape="circle"
|
||||
/>
|
||||
) : (
|
||||
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
a.text_left,
|
||||
a.leading_snug,
|
||||
size === 'large' ? {fontSize: 13} : a.text_xs,
|
||||
size === 'large' ? t.atoms.text : t.atoms.text_contrast_high,
|
||||
]}>
|
||||
{desc.name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||
</>
|
||||
<Pills.Row size={size} style={[size === 'sm' && {marginLeft: -3}, style]}>
|
||||
{modui.alerts.map(cause => (
|
||||
<Pills.Label
|
||||
key={getModerationCauseKey(cause)}
|
||||
cause={cause}
|
||||
size={size}
|
||||
noBg={size === 'sm'}
|
||||
/>
|
||||
))}
|
||||
{modui.informs.map(cause => (
|
||||
<Pills.Label
|
||||
key={getModerationCauseKey(cause)}
|
||||
cause={cause}
|
||||
size={size}
|
||||
noBg={size === 'sm'}
|
||||
/>
|
||||
))}
|
||||
</Pills.Row>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {
|
||||
BSKY_LABELER_DID,
|
||||
ModerationCause,
|
||||
ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import {ModerationDecision} from '@atproto/api'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {getModerationCauseKey} from 'lib/moderation'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Pills from '#/components/Pills'
|
||||
|
||||
export function ProfileHeaderAlerts({
|
||||
moderation,
|
||||
style,
|
||||
}: {
|
||||
moderation: ModerationDecision
|
||||
style?: StyleProp<ViewStyle>
|
||||
@@ -30,73 +17,21 @@ export function ProfileHeaderAlerts({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_col, a.gap_xs, style]}>
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
|
||||
{modui.alerts.map(cause => (
|
||||
<ProfileLabel key={getModerationCauseKey(cause)} cause={cause} />
|
||||
))}
|
||||
{modui.informs.map(cause => (
|
||||
<ProfileLabel key={getModerationCauseKey(cause)} cause={cause} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileLabel({
|
||||
cause,
|
||||
disableDetailsDialog,
|
||||
}: {
|
||||
cause: ModerationCause
|
||||
disableDetailsDialog?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const desc = useModerationCauseDescription(cause)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={disableDetailsDialog}
|
||||
label={desc.name}
|
||||
onPress={() => {
|
||||
control.open()
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{paddingLeft: 6, paddingRight: 8, paddingVertical: 4},
|
||||
a.gap_xs,
|
||||
a.rounded_md,
|
||||
hovered || pressed
|
||||
? t.atoms.bg_contrast_50
|
||||
: t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
{desc.sourceType === 'labeler' &&
|
||||
desc.sourceDid !== BSKY_LABELER_DID ? (
|
||||
<UserAvatar avatar={desc.sourceAvi} size={16} />
|
||||
) : (
|
||||
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
a.text_left,
|
||||
a.leading_snug,
|
||||
a.text_sm,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.font_semibold,
|
||||
]}>
|
||||
{desc.name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!disableDetailsDialog && (
|
||||
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||
)}
|
||||
</>
|
||||
<Pills.Row size="lg">
|
||||
{modui.alerts.map(cause => (
|
||||
<Pills.Label
|
||||
size="lg"
|
||||
key={getModerationCauseKey(cause)}
|
||||
cause={cause}
|
||||
/>
|
||||
))}
|
||||
{modui.informs.map(cause => (
|
||||
<Pills.Label
|
||||
size="lg"
|
||||
key={getModerationCauseKey(cause)}
|
||||
cause={cause}
|
||||
/>
|
||||
))}
|
||||
</Pills.Row>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ export const EMBED_SERVICE = 'https://embed.bsky.app'
|
||||
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
|
||||
|
||||
// HACK
|
||||
// Yes, this is exactly what it looks like. It's a hard-coded constant
|
||||
// reflecting the number of new users in the last week. We don't have
|
||||
// time to add a route to the servers for this so we're just going to hard
|
||||
// code and update this number with each release until we can get the
|
||||
// server route done.
|
||||
// -prf
|
||||
export const JOINED_THIS_WEEK = 21797 // as of Jul5 2024
|
||||
|
||||
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
||||
export function FEEDBACK_FORM_URL({
|
||||
email,
|
||||
|
||||
@@ -20,7 +20,9 @@ const openPermissionAlert = (perm: string) => {
|
||||
}
|
||||
|
||||
export function usePhotoLibraryPermission() {
|
||||
const [res, requestPermission] = MediaLibrary.usePermissions()
|
||||
const [res, requestPermission] = MediaLibrary.usePermissions({
|
||||
granularPermissions: ['photo'],
|
||||
})
|
||||
const requestPhotoAccessIfNeeded = async () => {
|
||||
// On the, we use <input type="file"> to produce a filepicker
|
||||
// This does not need any permission granting.
|
||||
@@ -46,6 +48,35 @@ export function usePhotoLibraryPermission() {
|
||||
return {requestPhotoAccessIfNeeded}
|
||||
}
|
||||
|
||||
export function useVideoLibraryPermission() {
|
||||
const [res, requestPermission] = MediaLibrary.usePermissions({
|
||||
granularPermissions: ['video'],
|
||||
})
|
||||
const requestVideoAccessIfNeeded = async () => {
|
||||
// On the, we use <input type="file"> to produce a filepicker
|
||||
// This does not need any permission granting.
|
||||
if (isWeb) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (res?.granted) {
|
||||
return true
|
||||
} else if (!res || res.status === 'undetermined' || res?.canAskAgain) {
|
||||
const {canAskAgain, granted, status} = await requestPermission()
|
||||
|
||||
if (!canAskAgain && status === 'undetermined') {
|
||||
openPermissionAlert('video library')
|
||||
}
|
||||
|
||||
return granted
|
||||
} else {
|
||||
openPermissionAlert('video library')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return {requestVideoAccessIfNeeded}
|
||||
}
|
||||
|
||||
export function useCameraPermission() {
|
||||
const [res, requestPermission] = Camera.useCameraPermissions()
|
||||
|
||||
|
||||
@@ -14,3 +14,11 @@ export function useCameraPermission() {
|
||||
|
||||
return {requestCameraAccessIfNeeded}
|
||||
}
|
||||
|
||||
export function useVideoLibraryPermission() {
|
||||
const requestVideoAccessIfNeeded = async () => {
|
||||
return true
|
||||
}
|
||||
|
||||
return {requestVideoAccessIfNeeded}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import {AppBskyFeedPost, BskyAgent} from '@atproto/api'
|
||||
import {AppBskyFeedPost, AppBskyGraphStarterpack, BskyAgent} from '@atproto/api'
|
||||
|
||||
import {useFetchDid} from '#/state/queries/handle'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {LikelyType, LinkMeta} from './link-meta'
|
||||
import {
|
||||
createStarterPackUri,
|
||||
parseStarterPackUri,
|
||||
} from 'lib/strings/starter-pack'
|
||||
import {ComposerOptsQuote} from 'state/shell/composer'
|
||||
// import {match as matchRoute} from 'view/routes'
|
||||
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
|
||||
import {ComposerOptsQuote} from 'state/shell/composer'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {useFetchDid} from '#/state/queries/handle'
|
||||
import {LikelyType, LinkMeta} from './link-meta'
|
||||
|
||||
// TODO
|
||||
// import {Home} from 'view/screens/Home'
|
||||
@@ -174,3 +179,39 @@ export async function getListAsEmbed(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStarterPackAsEmbed(
|
||||
agent: BskyAgent,
|
||||
fetchDid: ReturnType<typeof useFetchDid>,
|
||||
url: string,
|
||||
): Promise<apilib.ExternalEmbedDraft> {
|
||||
const parsed = parseStarterPackUri(url)
|
||||
if (!parsed) {
|
||||
throw new Error(
|
||||
'Unexepectedly called getStarterPackAsEmbed with a non-starterpack url',
|
||||
)
|
||||
}
|
||||
const did = await fetchDid(parsed.name)
|
||||
const starterPack = createStarterPackUri({did, rkey: parsed.rkey})
|
||||
const res = await agent.app.bsky.graph.getStarterPack({starterPack})
|
||||
const record = res.data.starterPack.record
|
||||
return {
|
||||
isLoading: false,
|
||||
uri: starterPack,
|
||||
meta: {
|
||||
url: starterPack,
|
||||
likelyType: LikelyType.AtpData,
|
||||
// Validation here should never fail
|
||||
title: AppBskyGraphStarterpack.isRecord(record)
|
||||
? record.name
|
||||
: 'Starter Pack',
|
||||
},
|
||||
embed: {
|
||||
$type: 'app.bsky.embed.record',
|
||||
record: {
|
||||
uri: res.data.starterPack.uri,
|
||||
cid: res.data.starterPack.cid,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {isBskyAppUrl} from '../strings/url-helpers'
|
||||
import {extractBskyMeta} from './bsky'
|
||||
|
||||
import {LINK_META_PROXY} from 'lib/constants'
|
||||
import {getGiphyMetaUri} from 'lib/strings/embed-player'
|
||||
import {parseStarterPackUri} from 'lib/strings/starter-pack'
|
||||
import {isBskyAppUrl} from '../strings/url-helpers'
|
||||
import {extractBskyMeta} from './bsky'
|
||||
|
||||
export enum LikelyType {
|
||||
HTML,
|
||||
@@ -28,7 +30,7 @@ export async function getLinkMeta(
|
||||
url: string,
|
||||
timeout = 15e3,
|
||||
): Promise<LinkMeta> {
|
||||
if (isBskyAppUrl(url)) {
|
||||
if (isBskyAppUrl(url) && !parseStarterPackUri(url)) {
|
||||
return extractBskyMeta(agent, url)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export async function resolveShortLink(shortLink: string) {
|
||||
const controller = new AbortController()
|
||||
const to = setTimeout(() => controller.abort(), 2e3)
|
||||
|
||||
try {
|
||||
const res = await fetch(shortLink, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
logger.error('Failed to resolve short link', {status: res.status})
|
||||
return shortLink
|
||||
}
|
||||
const json = (await res.json()) as {url: string}
|
||||
return json.url
|
||||
} catch (e: unknown) {
|
||||
logger.error('Failed to resolve short link', {safeMessage: e})
|
||||
return shortLink
|
||||
} finally {
|
||||
clearTimeout(to)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {getVideoMetaData, Video} from 'react-native-compressor'
|
||||
|
||||
export type CompressedVideo = {
|
||||
uri: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export async function compressVideo(
|
||||
file: string,
|
||||
opts?: {
|
||||
getCancellationId?: (id: string) => void
|
||||
onProgress?: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {onProgress, getCancellationId} = opts || {}
|
||||
|
||||
const compressed = await Video.compress(
|
||||
file,
|
||||
{
|
||||
getCancellationId,
|
||||
compressionMethod: 'manual',
|
||||
bitrate: 3_000_000, // 3mbps
|
||||
maxSize: 1920,
|
||||
},
|
||||
onProgress,
|
||||
)
|
||||
|
||||
const info = await getVideoMetaData(compressed)
|
||||
return {uri: compressed, size: info.size}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||
|
||||
const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
|
||||
|
||||
export type CompressedVideo = {
|
||||
uri: string
|
||||
size: number
|
||||
}
|
||||
|
||||
// doesn't actually compress, but throws if >100MB
|
||||
export async function compressVideo(
|
||||
file: string,
|
||||
_callbacks?: {
|
||||
onProgress: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const blob = await fetch(file).then(res => res.blob())
|
||||
const video = URL.createObjectURL(blob)
|
||||
|
||||
if (blob.size > MAX_VIDEO_SIZE) {
|
||||
throw new VideoTooLargeError()
|
||||
}
|
||||
|
||||
return {
|
||||
size: blob.size,
|
||||
uri: video,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class VideoTooLargeError extends Error {
|
||||
constructor() {
|
||||
super('Videos cannot be larger than 100MB')
|
||||
this.name = 'VideoTooLargeError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
|
||||
export function isBlockedOrBlocking(
|
||||
profile:
|
||||
| AppBskyActorDefs.ProfileViewBasic
|
||||
| AppBskyActorDefs.ProfileViewDetailed,
|
||||
) {
|
||||
return profile.viewer?.blockedBy || profile.viewer?.blocking
|
||||
}
|
||||
|
||||
export function isMuted(
|
||||
profile:
|
||||
| AppBskyActorDefs.ProfileViewBasic
|
||||
| AppBskyActorDefs.ProfileViewDetailed,
|
||||
) {
|
||||
return profile.viewer?.muted || profile.viewer?.mutedByList
|
||||
}
|
||||
@@ -12,10 +12,6 @@ export function createSanitizedDisplayName(
|
||||
if (profile.displayName != null && profile.displayName !== '') {
|
||||
return sanitizeDisplayName(profile.displayName)
|
||||
} else {
|
||||
let sanitizedHandle = sanitizeHandle(profile.handle)
|
||||
if (!noAt) {
|
||||
sanitizedHandle = `@${sanitizedHandle}`
|
||||
}
|
||||
return sanitizedHandle
|
||||
return sanitizeHandle(profile.handle, noAt ? '' : '@')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export type CommonNavigatorParams = {
|
||||
Feeds: undefined
|
||||
Start: {name: string; rkey: string}
|
||||
StarterPack: {name: string; rkey: string; new?: boolean}
|
||||
StarterPackShort: {code: string}
|
||||
StarterPackWizard: undefined
|
||||
StarterPackEdit: {
|
||||
rkey?: string
|
||||
@@ -101,6 +102,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
Messages: {animation?: 'push' | 'pop'}
|
||||
Start: {name: string; rkey: string}
|
||||
StarterPack: {name: string; rkey: string; new?: boolean}
|
||||
StarterPackShort: {code: string}
|
||||
StarterPackWizard: undefined
|
||||
StarterPackEdit: {
|
||||
rkey?: string
|
||||
|
||||
@@ -31,7 +31,13 @@ export type LogEvents = {
|
||||
'splash:createAccountPressed': {}
|
||||
'signup:nextPressed': {
|
||||
activeStep: number
|
||||
phoneVerificationRequired?: boolean
|
||||
}
|
||||
'signup:backPressed': {
|
||||
activeStep: number
|
||||
}
|
||||
'signup:captchaSuccess': {}
|
||||
'signup:captchaFailure': {}
|
||||
'onboarding:interests:nextPressed': {
|
||||
selectedInterests: string[]
|
||||
selectedInterestsLength: number
|
||||
@@ -147,6 +153,7 @@ export type LogEvents = {
|
||||
| 'ProfileHoverCard'
|
||||
| 'AvatarButton'
|
||||
| 'StarterPackProfilesList'
|
||||
| 'FeedInterstitial'
|
||||
}
|
||||
'profile:unfollow': {
|
||||
logContext:
|
||||
@@ -160,6 +167,7 @@ export type LogEvents = {
|
||||
| 'Chat'
|
||||
| 'AvatarButton'
|
||||
| 'StarterPackProfilesList'
|
||||
| 'FeedInterstitial'
|
||||
}
|
||||
'chat:create': {
|
||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||
@@ -188,6 +196,15 @@ export type LogEvents = {
|
||||
profilesCount: number
|
||||
feedsCount: number
|
||||
}
|
||||
'starterPack:ctaPress': {
|
||||
starterPack: string
|
||||
}
|
||||
'starterPack:opened': {
|
||||
starterPack: string
|
||||
}
|
||||
|
||||
'feed:interstitial:profileCard:press': {}
|
||||
'feed:interstitial:feedCard:press': {}
|
||||
|
||||
'test:all:always': {}
|
||||
'test:all:sometimes': {}
|
||||
|
||||
@@ -2,7 +2,13 @@ export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'debug_show_feedcontext'
|
||||
| 'native_pwi_disabled'
|
||||
| 'new_user_guided_tour'
|
||||
| 'new_user_progress_guide'
|
||||
| 'onboarding_minimum_interests'
|
||||
| 'request_notifications_permission_after_onboarding_v2'
|
||||
| 'show_avi_follow_button'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'starter_packs_enabled'
|
||||
| 'suggested_feeds_interstitial'
|
||||
| 'suggested_follows_interstitial'
|
||||
| 'ungroup_follow_backs'
|
||||
| 'videos'
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
const LEFT_TO_RIGHT_EMBEDDING = '\u202A'
|
||||
const POP_DIRECTIONAL_FORMATTING = '\u202C'
|
||||
|
||||
/*
|
||||
* Force LTR directionality in a string.
|
||||
* https://www.unicode.org/reports/tr9/#Directional_Formatting_Characters
|
||||
*/
|
||||
export function forceLTR(str: string) {
|
||||
return LEFT_TO_RIGHT_EMBEDDING + str + POP_DIRECTIONAL_FORMATTING
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Regex from the go implementation
|
||||
// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
|
||||
import {forceLTR} from 'lib/strings/bidi'
|
||||
|
||||
const VALIDATE_REGEX =
|
||||
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
||||
|
||||
@@ -22,7 +24,9 @@ export function isInvalidHandle(handle: string): boolean {
|
||||
}
|
||||
|
||||
export function sanitizeHandle(handle: string, prefix = ''): string {
|
||||
return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}`
|
||||
return isInvalidHandle(handle)
|
||||
? '⚠Invalid Handle'
|
||||
: forceLTR(`${prefix}${handle}`)
|
||||
}
|
||||
|
||||
export interface IsValidHandle {
|
||||
|
||||
@@ -96,6 +96,10 @@ export function createStarterPackUri({
|
||||
}: {
|
||||
did: string
|
||||
rkey: string
|
||||
}): string | null {
|
||||
}): string {
|
||||
return new AtUri(`at://${did}/app.bsky.graph.starterpack/${rkey}`).toString()
|
||||
}
|
||||
|
||||
export function startUriToStarterPackUri(uri: string) {
|
||||
return uri.replace('/start/', '/starter-pack/')
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import {AtUri} from '@atproto/api'
|
||||
import psl from 'psl'
|
||||
import TLDs from 'tlds'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {BSKY_SERVICE} from 'lib/constants'
|
||||
import {isInvalidHandle} from 'lib/strings/handles'
|
||||
import {startUriToStarterPackUri} from 'lib/strings/starter-pack'
|
||||
|
||||
export const BSKY_APP_HOST = 'https://bsky.app'
|
||||
const BSKY_TRUSTED_HOSTS = [
|
||||
@@ -151,6 +153,30 @@ export function isBskyListUrl(url: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function isBskyStartUrl(url: string): boolean {
|
||||
if (isBskyAppUrl(url)) {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
return /start\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
|
||||
} catch {
|
||||
console.error('Unexpected error in isBskyStartUrl()', url)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isBskyStarterPackUrl(url: string): boolean {
|
||||
if (isBskyAppUrl(url)) {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
return /starter-pack\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
|
||||
} catch {
|
||||
console.error('Unexpected error in isBskyStartUrl()', url)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isBskyDownloadUrl(url: string): boolean {
|
||||
if (isExternalUrl(url)) {
|
||||
return false
|
||||
@@ -162,10 +188,18 @@ export function convertBskyAppUrlIfNeeded(url: string): string {
|
||||
if (isBskyAppUrl(url)) {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
|
||||
if (isBskyStartUrl(url)) {
|
||||
return startUriToStarterPackUri(urlp.pathname)
|
||||
}
|
||||
|
||||
return urlp.pathname
|
||||
} catch (e) {
|
||||
console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
|
||||
}
|
||||
} else if (isShortLink(url)) {
|
||||
// We only want to do this on native, web handles the 301 for us
|
||||
return shortLinkToHref(url)
|
||||
}
|
||||
return url
|
||||
}
|
||||
@@ -285,3 +319,23 @@ export function createBskyAppAbsoluteUrl(path: string): string {
|
||||
const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '')
|
||||
return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}`
|
||||
}
|
||||
|
||||
export function isShortLink(url: string): boolean {
|
||||
return url.startsWith('https://go.bsky.app/')
|
||||
}
|
||||
|
||||
export function shortLinkToHref(url: string): string {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
|
||||
// For now we only support starter packs, but in the future we should add additional paths to this check
|
||||
const parts = urlp.pathname.split('/').filter(Boolean)
|
||||
if (parts.length === 1) {
|
||||
return `/starter-pack-short/${parts[0]}`
|
||||
}
|
||||
return url
|
||||
} catch (e) {
|
||||
logger.error('Failed to parse possible short link', {safeMessage: e})
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
+1637
-752
File diff suppressed because it is too large
Load Diff
+1583
-694
File diff suppressed because it is too large
Load Diff
+1579
-690
File diff suppressed because it is too large
Load Diff
+1584
-695
File diff suppressed because it is too large
Load Diff
+1583
-694
File diff suppressed because it is too large
Load Diff
+1539
-716
File diff suppressed because it is too large
Load Diff
+1584
-695
File diff suppressed because it is too large
Load Diff
+1581
-700
File diff suppressed because it is too large
Load Diff
+1842
-953
File diff suppressed because it is too large
Load Diff
+1583
-697
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user