mirror of
https://github.com/tooot-app/app
synced 2025-02-13 10:20:44 +01:00
commit
ef6eec5351
@ -4,12 +4,19 @@
|
||||
|
||||
![GitHub Workflow Status](https://img.shields.io/github/workflow/status/tooot-app/app/build) ![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/tooot-app/app/build/candidate?label=build%20candidate) ![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/tooot-app/app/build/release?label=build%20release)
|
||||
|
||||
## Contribute to translation
|
||||
|
||||
Please **do not** create a pull request to update translation. tooot's translation is managed through [https://crowdin.tooot.app/](https://crowdin.tooot.app/) and Crowdin struggles to properly sync two ways. If there is a minor update and you do not want to register an account on Crowdin, please open an issue.
|
||||
|
||||
|
||||
## Special thanks
|
||||
|
||||
[@forenta](https://github.com/forenta) for German translation
|
||||
|
||||
[@andrigamerita](https://github.com/andrigamerita) for Italian translation
|
||||
|
||||
[@Hikaru](https://github.com/Hikali-47041) and [@la_la](https://mstdn.jp/@la_la_la) for Japanese translation
|
||||
|
||||
[@hellojaccc](https://github.com/hellojaccc) for Korean translation
|
||||
|
||||
[@luizpicolo](https://github.com/luizpicolo) for Brazilian Portuguese
|
||||
|
@ -1,4 +1,4 @@
|
||||
languages(['zh-Hans', 'vi', 'ko', 'en-US', 'de-DE'])
|
||||
languages(['de-DE', 'en-US', 'it', 'ko', 'pt-BR', 'vi', 'zh-Hans'])
|
||||
|
||||
name({
|
||||
'default' => "tooot"
|
||||
|
@ -3,7 +3,7 @@
|
||||
"versions": {
|
||||
"major": 4,
|
||||
"minor": 3,
|
||||
"patch": 0
|
||||
"patch": 1
|
||||
},
|
||||
"description": "tooot app for Mastodon",
|
||||
"author": "xmflsct <me@xmflsct.com>",
|
||||
@ -86,6 +86,7 @@
|
||||
"react-native-language-detection": "^0.1.0",
|
||||
"react-native-pager-view": "^5.4.25",
|
||||
"react-native-reanimated": "^2.9.1",
|
||||
"react-native-reanimated-zoom": "^0.3.0",
|
||||
"react-native-safe-area-context": "^4.3.1",
|
||||
"react-native-screens": "^3.16.0",
|
||||
"react-native-share-menu": "^6.0.0",
|
||||
|
@ -67,9 +67,7 @@ const GracefullyImage = ({
|
||||
const onLoad = () => {
|
||||
setImageLoaded(true)
|
||||
if (setImageDimensions && source.uri) {
|
||||
Image.getSize(source.uri, (width, height) =>
|
||||
setImageDimensions({ width, height })
|
||||
)
|
||||
Image.getSize(source.uri, (width, height) => setImageDimensions({ width, height }))
|
||||
}
|
||||
}
|
||||
const onError = () => {
|
||||
@ -81,22 +79,9 @@ const GracefullyImage = ({
|
||||
const blurhashView = useMemo(() => {
|
||||
if (hidden || !imageLoaded) {
|
||||
if (blurhash) {
|
||||
return (
|
||||
<Blurhash
|
||||
decodeAsync
|
||||
blurhash={blurhash}
|
||||
style={styles.placeholder}
|
||||
/>
|
||||
)
|
||||
return <Blurhash decodeAsync blurhash={blurhash} style={styles.placeholder} />
|
||||
} else {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.placeholder,
|
||||
{ backgroundColor: colors.shimmerDefault }
|
||||
]}
|
||||
/>
|
||||
)
|
||||
return <View style={[styles.placeholder, { backgroundColor: colors.shimmerDefault }]} />
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
@ -105,26 +90,17 @@ const GracefullyImage = ({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...(onPress
|
||||
? { accessibilityRole: 'imagebutton' }
|
||||
: { accessibilityRole: 'image' })}
|
||||
{...(onPress ? { accessibilityRole: 'imagebutton' } : { accessibilityRole: 'image' })}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityHint={accessibilityHint}
|
||||
style={[style, dimension, { backgroundColor: colors.shimmerDefault }]}
|
||||
{...(onPress
|
||||
? hidden
|
||||
? { disabled: true }
|
||||
: { onPress }
|
||||
: { disabled: true })}
|
||||
{...(onPress ? (hidden ? { disabled: true } : { onPress }) : { disabled: true })}
|
||||
>
|
||||
{uri.preview && !imageLoaded ? (
|
||||
<Image
|
||||
fadeDuration={0}
|
||||
source={{ uri: uri.preview }}
|
||||
style={[
|
||||
styles.placeholder,
|
||||
{ backgroundColor: colors.shimmerDefault }
|
||||
]}
|
||||
style={[styles.placeholder, { backgroundColor: colors.shimmerDefault }]}
|
||||
/>
|
||||
) : null}
|
||||
{Platform.OS === 'ios' ? (
|
||||
|
@ -11,7 +11,7 @@ import { RootStackParamList } from '@utils/navigation/navigators'
|
||||
import { getInstanceAccount } from '@utils/slices/instancesSlice'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import layoutAnimation from '@utils/styles/layoutAnimation'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Pressable, View } from 'react-native'
|
||||
import { useSelector } from 'react-redux'
|
||||
@ -27,8 +27,7 @@ const TimelineAttachment = React.memo(
|
||||
const account = useSelector(
|
||||
getInstanceAccount,
|
||||
(prev, next) =>
|
||||
prev.preferences['reading:expand:media'] ===
|
||||
next.preferences['reading:expand:media']
|
||||
prev.preferences['reading:expand:media'] === next.preferences['reading:expand:media']
|
||||
)
|
||||
const defaultSensitive = () => {
|
||||
switch (account.preferences['reading:expand:media']) {
|
||||
@ -42,15 +41,47 @@ const TimelineAttachment = React.memo(
|
||||
}
|
||||
const [sensitiveShown, setSensitiveShown] = useState(defaultSensitive())
|
||||
|
||||
const imageUrls = useRef<
|
||||
RootStackParamList['Screen-ImagesViewer']['imageUrls']
|
||||
>([])
|
||||
const imageUrls: RootStackParamList['Screen-ImagesViewer']['imageUrls'] =
|
||||
status.media_attachments
|
||||
.map(attachment => {
|
||||
switch (attachment.type) {
|
||||
case 'image':
|
||||
return {
|
||||
id: attachment.id,
|
||||
preview_url: attachment.preview_url,
|
||||
url: attachment.url,
|
||||
remote_url: attachment.remote_url,
|
||||
blurhash: attachment.blurhash,
|
||||
width: attachment.meta?.original?.width,
|
||||
height: attachment.meta?.original?.height
|
||||
}
|
||||
default:
|
||||
if (
|
||||
attachment.preview_url?.endsWith('.jpg') ||
|
||||
attachment.preview_url?.endsWith('.jpeg') ||
|
||||
attachment.preview_url?.endsWith('.png') ||
|
||||
attachment.preview_url?.endsWith('.gif') ||
|
||||
attachment.remote_url?.endsWith('.jpg') ||
|
||||
attachment.remote_url?.endsWith('.jpeg') ||
|
||||
attachment.remote_url?.endsWith('.png') ||
|
||||
attachment.remote_url?.endsWith('.gif')
|
||||
) {
|
||||
return {
|
||||
id: attachment.id,
|
||||
preview_url: attachment.preview_url,
|
||||
url: attachment.url,
|
||||
remote_url: attachment.remote_url,
|
||||
blurhash: attachment.blurhash,
|
||||
width: attachment.meta?.original?.width,
|
||||
height: attachment.meta?.original?.height
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter(i => i)
|
||||
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>()
|
||||
const navigateToImagesViewer = (id: string) => {
|
||||
navigation.navigate('Screen-ImagesViewer', {
|
||||
imageUrls: imageUrls.current,
|
||||
id
|
||||
})
|
||||
navigation.navigate('Screen-ImagesViewer', { imageUrls, id })
|
||||
}
|
||||
|
||||
return (
|
||||
@ -68,15 +99,6 @@ const TimelineAttachment = React.memo(
|
||||
{status.media_attachments.map((attachment, index) => {
|
||||
switch (attachment.type) {
|
||||
case 'image':
|
||||
imageUrls.current.push({
|
||||
id: attachment.id,
|
||||
preview_url: attachment.preview_url,
|
||||
url: attachment.url,
|
||||
remote_url: attachment.remote_url,
|
||||
blurhash: attachment.blurhash,
|
||||
width: attachment.meta?.original?.width,
|
||||
height: attachment.meta?.original?.height
|
||||
})
|
||||
return (
|
||||
<AttachmentImage
|
||||
key={index}
|
||||
@ -129,15 +151,6 @@ const TimelineAttachment = React.memo(
|
||||
attachment.remote_url?.endsWith('.png') ||
|
||||
attachment.remote_url?.endsWith('.gif')
|
||||
) {
|
||||
imageUrls.current.push({
|
||||
id: attachment.id,
|
||||
preview_url: attachment.preview_url,
|
||||
url: attachment.url,
|
||||
remote_url: attachment.remote_url,
|
||||
blurhash: attachment.blurhash,
|
||||
width: attachment.meta?.original?.width,
|
||||
height: attachment.meta?.original?.height
|
||||
})
|
||||
return (
|
||||
<AttachmentImage
|
||||
key={index}
|
||||
@ -212,19 +225,13 @@ const TimelineAttachment = React.memo(
|
||||
(prev, next) => {
|
||||
let isEqual = true
|
||||
|
||||
if (
|
||||
prev.status.media_attachments.length !==
|
||||
next.status.media_attachments.length
|
||||
) {
|
||||
if (prev.status.media_attachments.length !== next.status.media_attachments.length) {
|
||||
isEqual = false
|
||||
return isEqual
|
||||
}
|
||||
|
||||
prev.status.media_attachments.forEach((attachment, index) => {
|
||||
if (
|
||||
attachment.preview_url !==
|
||||
next.status.media_attachments[index].preview_url
|
||||
) {
|
||||
if (attachment.preview_url !== next.status.media_attachments[index].preview_url) {
|
||||
isEqual = false
|
||||
}
|
||||
})
|
||||
|
@ -4,7 +4,7 @@
|
||||
"title": "Benutzeraktionen",
|
||||
"mute": {
|
||||
"action_false": "Profil stummschalten",
|
||||
"action_true": ""
|
||||
"action_true": "Stummschaltung Nutzers aufheben"
|
||||
},
|
||||
"block": {
|
||||
"action_false": "Nutzer blockieren",
|
||||
@ -16,7 +16,7 @@
|
||||
},
|
||||
"copy": {
|
||||
"action": "",
|
||||
"succeed": ""
|
||||
"succeed": "Kopiert"
|
||||
},
|
||||
"instance": {
|
||||
"title": "",
|
||||
@ -65,7 +65,7 @@
|
||||
}
|
||||
},
|
||||
"mute": {
|
||||
"action_false": "",
|
||||
"action_false": "Diesen Tröt sowie die Antworten stummschalten",
|
||||
"action_true": ""
|
||||
},
|
||||
"pin": {
|
||||
|
@ -11,7 +11,7 @@
|
||||
"domains": "Universes"
|
||||
},
|
||||
"disclaimer": {
|
||||
"base": "Logging in process uses system broswer that, your account information won't be visible to tooot app."
|
||||
"base": "Logging in process uses system browser that, your account information won't be visible to tooot app."
|
||||
},
|
||||
"terms": {
|
||||
"base": "By logging in, you agree to the <0>privacy policy</0> and <1>terms of service</1>."
|
||||
|
@ -4,6 +4,7 @@ import { initReactI18next } from 'react-i18next'
|
||||
import de from '@root/i18n/de/_all'
|
||||
import en from '@root/i18n/en/_all'
|
||||
import it from '@root/i18n/it/_all'
|
||||
import ja from '@root/i18n/ja/_all'
|
||||
import ko from '@root/i18n/ko/_all'
|
||||
import pt_BR from '@root/i18n/pt_BR/_all'
|
||||
import vi from '@root/i18n/vi/_all'
|
||||
@ -16,6 +17,7 @@ import '@formatjs/intl-pluralrules/polyfill'
|
||||
import '@formatjs/intl-pluralrules/locale-data/de'
|
||||
import '@formatjs/intl-pluralrules/locale-data/en'
|
||||
import '@formatjs/intl-pluralrules/locale-data/it'
|
||||
import '@formatjs/intl-pluralrules/locale-data/ja'
|
||||
import '@formatjs/intl-pluralrules/locale-data/ko'
|
||||
import '@formatjs/intl-pluralrules/locale-data/pt'
|
||||
import '@formatjs/intl-pluralrules/locale-data/vi'
|
||||
@ -25,6 +27,7 @@ import '@formatjs/intl-numberformat/polyfill'
|
||||
import '@formatjs/intl-numberformat/locale-data/de'
|
||||
import '@formatjs/intl-numberformat/locale-data/en'
|
||||
import '@formatjs/intl-numberformat/locale-data/it'
|
||||
import '@formatjs/intl-numberformat/locale-data/ja'
|
||||
import '@formatjs/intl-numberformat/locale-data/ko'
|
||||
import '@formatjs/intl-numberformat/locale-data/pt'
|
||||
import '@formatjs/intl-numberformat/locale-data/vi'
|
||||
@ -34,6 +37,7 @@ import '@formatjs/intl-datetimeformat/polyfill'
|
||||
import '@formatjs/intl-datetimeformat/locale-data/de'
|
||||
import '@formatjs/intl-datetimeformat/locale-data/en'
|
||||
import '@formatjs/intl-datetimeformat/locale-data/it'
|
||||
import '@formatjs/intl-datetimeformat/locale-data/ja'
|
||||
import '@formatjs/intl-datetimeformat/locale-data/ko'
|
||||
import '@formatjs/intl-datetimeformat/locale-data/pt'
|
||||
import '@formatjs/intl-datetimeformat/locale-data/vi'
|
||||
@ -44,6 +48,7 @@ import '@formatjs/intl-relativetimeformat/polyfill'
|
||||
import '@formatjs/intl-relativetimeformat/locale-data/de'
|
||||
import '@formatjs/intl-relativetimeformat/locale-data/en'
|
||||
import '@formatjs/intl-relativetimeformat/locale-data/it'
|
||||
import '@formatjs/intl-relativetimeformat/locale-data/ja'
|
||||
import '@formatjs/intl-relativetimeformat/locale-data/ko'
|
||||
import '@formatjs/intl-relativetimeformat/locale-data/pt'
|
||||
import '@formatjs/intl-relativetimeformat/locale-data/vi'
|
||||
@ -56,7 +61,7 @@ i18n.use(initReactI18next).init({
|
||||
ns: ['common'],
|
||||
defaultNS: 'common',
|
||||
|
||||
resources: { 'zh-Hans': zh_Hans, vi, 'pt-BR': pt_BR, ko, it, en, de },
|
||||
resources: { de, en, it, ja, ko, 'pt-BR': pt_BR, vi, 'zh-Hans': zh_Hans },
|
||||
returnEmptyString: false,
|
||||
|
||||
saveMissing: true,
|
||||
|
@ -16,7 +16,7 @@
|
||||
},
|
||||
"copy": {
|
||||
"action": "",
|
||||
"succeed": ""
|
||||
"succeed": "Copiato"
|
||||
},
|
||||
"instance": {
|
||||
"title": "Azione sull'istanza",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"heading": "",
|
||||
"heading": "Condividi con...",
|
||||
"content": {
|
||||
"select_account": ""
|
||||
"select_account": "Seleziona conto"
|
||||
}
|
||||
}
|
18
src/i18n/ja/_all.ts
Normal file
18
src/i18n/ja/_all.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export default {
|
||||
common: require('./common'),
|
||||
|
||||
screens: require('./screens'),
|
||||
screenActions: require('./screens/actions'),
|
||||
screenAnnouncements: require('./screens/announcements'),
|
||||
screenCompose: require('./screens/compose'),
|
||||
screenImageViewer: require('./screens/imageViewer'),
|
||||
screenTabs: require('./screens/tabs'),
|
||||
|
||||
componentContextMenu: require('./components/contextMenu'),
|
||||
componentEmojis: require('./components/emojis'),
|
||||
componentInstance: require('./components/instance'),
|
||||
componentMediaSelector: require('./components/mediaSelector'),
|
||||
componentParse: require('./components/parse'),
|
||||
componentRelationship: require('./components/relationship'),
|
||||
componentTimeline: require('./components/timeline')
|
||||
}
|
@ -1,22 +1,22 @@
|
||||
{
|
||||
"buttons": {
|
||||
"OK": "",
|
||||
"apply": "",
|
||||
"cancel": ""
|
||||
"OK": "はい",
|
||||
"apply": "適用",
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"customEmoji": {
|
||||
"accessibilityLabel": ""
|
||||
"accessibilityLabel": "カスタム絵文字 {{emoji}}"
|
||||
},
|
||||
"message": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "{{function}} が成功しました"
|
||||
},
|
||||
"warning": {
|
||||
"message": ""
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "{{function}} 接続に失敗しました。再試行してください。"
|
||||
}
|
||||
},
|
||||
"separator": ""
|
||||
"separator": ", "
|
||||
}
|
@ -1,76 +1,76 @@
|
||||
{
|
||||
"accessibilityHint": "",
|
||||
"accessibilityHint": "このトゥートへのアクション、投稿されたユーザー、トゥート自体など",
|
||||
"account": {
|
||||
"title": "",
|
||||
"title": "ユーザーアクション",
|
||||
"mute": {
|
||||
"action_false": "",
|
||||
"action_true": ""
|
||||
"action_false": "ユーザーをミュート",
|
||||
"action_true": "ユーザーのミュートを解除"
|
||||
},
|
||||
"block": {
|
||||
"action_false": "",
|
||||
"action_true": ""
|
||||
"action_false": "ユーザーをブロック",
|
||||
"action_true": "ユーザーのブロックを解除"
|
||||
},
|
||||
"reports": {
|
||||
"action": ""
|
||||
"action": "ユーザーを報告"
|
||||
}
|
||||
},
|
||||
"copy": {
|
||||
"action": "",
|
||||
"succeed": ""
|
||||
"action": "トゥートをコピー",
|
||||
"succeed": "コピー完了"
|
||||
},
|
||||
"instance": {
|
||||
"title": "",
|
||||
"title": "インスタンスアクション",
|
||||
"block": {
|
||||
"action": "",
|
||||
"action": "インスタンスをブロック {{instance}}",
|
||||
"alert": {
|
||||
"title": "",
|
||||
"message": "",
|
||||
"title": "インスタンス {{instance}} をブロックしますか?",
|
||||
"message": "ほとんどの場合、特定のユーザーをミュートまたはブロックすることができます。\n\nインスタンスをブロックすると、このインスタンスからフォロワーを含むすべてのコンテンツが削除されます!",
|
||||
"buttons": {
|
||||
"confirm": ""
|
||||
"confirm": "確定"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"status": {
|
||||
"action": ""
|
||||
"action": "トゥートを共有"
|
||||
},
|
||||
"account": {
|
||||
"action": ""
|
||||
"action": "ユーザーを共有"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "",
|
||||
"title": "トゥートのアクション",
|
||||
"edit": {
|
||||
"action": ""
|
||||
"action": "トゥートを編集"
|
||||
},
|
||||
"delete": {
|
||||
"action": "",
|
||||
"action": "トゥートを削除",
|
||||
"alert": {
|
||||
"title": "",
|
||||
"message": "",
|
||||
"title": "削除しますか?",
|
||||
"message": "この投稿へのすべてのお気に入り登録やブーストは消去され、すべての返信は孤立することになります。",
|
||||
"buttons": {
|
||||
"confirm": ""
|
||||
"confirm": "確定"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteEdit": {
|
||||
"action": "",
|
||||
"action": "トゥートを削除し、再投稿する",
|
||||
"alert": {
|
||||
"title": "",
|
||||
"message": "",
|
||||
"title": "削除して再投稿しますか?",
|
||||
"message": "この投稿へのすべてのお気に入り登録やブーストは消去され、すべての返信は孤立することになります。",
|
||||
"buttons": {
|
||||
"confirm": ""
|
||||
"confirm": "確定"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mute": {
|
||||
"action_false": "",
|
||||
"action_true": ""
|
||||
"action_false": "トゥートと返信をミュート",
|
||||
"action_true": "トゥートと返信のミュートを解除"
|
||||
},
|
||||
"pin": {
|
||||
"action_false": "",
|
||||
"action_true": ""
|
||||
"action_false": "トゥートを固定",
|
||||
"action_true": "トゥートの固定を解除"
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +1,3 @@
|
||||
{}
|
||||
{
|
||||
"frequentUsed": "よく使う絵文字"
|
||||
}
|
@ -1,29 +1,29 @@
|
||||
{
|
||||
"server": {
|
||||
"textInput": {
|
||||
"placeholder": ""
|
||||
"placeholder": "インスタンスのドメイン"
|
||||
},
|
||||
"button": "",
|
||||
"button": "ログイン",
|
||||
"information": {
|
||||
"name": "",
|
||||
"accounts": "",
|
||||
"statuses": "",
|
||||
"domains": ""
|
||||
"name": "名前",
|
||||
"accounts": "ユーザー",
|
||||
"statuses": "トゥート",
|
||||
"domains": "接続連合数"
|
||||
},
|
||||
"disclaimer": {
|
||||
"base": ""
|
||||
"base": "ログインにはシステムのブラウザを使用するため、あなたのアカウント情報は tooot アプリには表示されません。"
|
||||
},
|
||||
"terms": {
|
||||
"base": ""
|
||||
"base": "ログインすることで、 tooot の <0>プライバシーポリシー</0> および <1>利用規約</1> に同意したことになります。"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"alert": {
|
||||
"title": "",
|
||||
"message": "",
|
||||
"title": "このインスタンスにログインしました",
|
||||
"message": "既存のアカウントにログインしたまま、他のアカウントにログインできます",
|
||||
"buttons": {
|
||||
"cancel": "",
|
||||
"continue": ""
|
||||
"cancel": "$t(common:buttons.cancel)",
|
||||
"continue": "続行"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,17 @@
|
||||
{
|
||||
"title": "",
|
||||
"title": "メディアソースを選択",
|
||||
"options": {
|
||||
"image": "",
|
||||
"image_max": "",
|
||||
"video": "",
|
||||
"video_max": ""
|
||||
"image": "写真をアップロード",
|
||||
"image_max": "写真をアップロード (最大{{max}}枚)",
|
||||
"video": "動画をアップロード",
|
||||
"video_max": "動画をアップロード (最大{{max}}本)"
|
||||
},
|
||||
"library": {
|
||||
"alert": {
|
||||
"title": "",
|
||||
"message": "",
|
||||
"title": "権限がありません",
|
||||
"message": "アップロードするにはフォトライブラリの読み取り許可が必要です",
|
||||
"buttons": {
|
||||
"settings": ""
|
||||
"settings": "設定を更新する"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
{
|
||||
"HTML": {
|
||||
"accessibilityHint": "",
|
||||
"accessibilityHint": "タップして内容を展開または折りたたむ",
|
||||
"expanded": "{{hint}}{{totalLines}}",
|
||||
"totalLines": "",
|
||||
"defaultHint": ""
|
||||
"totalLines": " ({{count}} 行)",
|
||||
"defaultHint": "長いトゥート"
|
||||
}
|
||||
}
|
@ -1,16 +1,16 @@
|
||||
{
|
||||
"follow": {
|
||||
"function": ""
|
||||
"function": "ユーザーをフォロー"
|
||||
},
|
||||
"block": {
|
||||
"function": ""
|
||||
"function": "ユーザーをブロック"
|
||||
},
|
||||
"button": {
|
||||
"error": "",
|
||||
"blocked_by": "",
|
||||
"blocking": "",
|
||||
"following": "",
|
||||
"requested": "",
|
||||
"default": ""
|
||||
"error": "読み込みエラー",
|
||||
"blocked_by": "ユーザーによってブロック",
|
||||
"blocking": "ブロックを解除",
|
||||
"following": "フォローをやめる",
|
||||
"requested": "リクエストを削除",
|
||||
"default": "フォロー"
|
||||
}
|
||||
}
|
@ -1,145 +1,145 @@
|
||||
{
|
||||
"empty": {
|
||||
"error": {
|
||||
"message": "",
|
||||
"button": ""
|
||||
"message": "読み込みエラー",
|
||||
"button": "再試行"
|
||||
},
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "表示するものがありません"
|
||||
}
|
||||
},
|
||||
"end": {
|
||||
"message": ""
|
||||
"message": "スレッドの末端です。コーヒーはいかがですか <0 />"
|
||||
},
|
||||
"lookback": {
|
||||
"message": ""
|
||||
"message": "最終閲覧"
|
||||
},
|
||||
"refresh": {
|
||||
"fetchPreviousPage": "",
|
||||
"refetch": ""
|
||||
"fetchPreviousPage": "前のページを取得",
|
||||
"refetch": "更新"
|
||||
},
|
||||
"shared": {
|
||||
"actioned": {
|
||||
"pinned": "",
|
||||
"favourite": "",
|
||||
"status": "",
|
||||
"follow": "",
|
||||
"follow_request": "",
|
||||
"poll": "",
|
||||
"pinned": "固定された投稿",
|
||||
"favourite": "{name}さんがあなたのトゥートをお気に入りに登録しました",
|
||||
"status": "{name}さんが投稿しました",
|
||||
"follow": "{name}さんにフォローされました",
|
||||
"follow_request": "{{name}}さんがフォローをリクエストしました",
|
||||
"poll": "アンケートが終了しました",
|
||||
"reblog": {
|
||||
"default": "",
|
||||
"notification": ""
|
||||
"default": "{name}さんがブースト",
|
||||
"notification": "{name}さんがあなたのトゥートをブーストしました"
|
||||
},
|
||||
"update": ""
|
||||
"update": "ブーストしたトゥートが編集されました"
|
||||
},
|
||||
"actions": {
|
||||
"reply": {
|
||||
"accessibilityLabel": ""
|
||||
"accessibilityLabel": "このトゥートに返信"
|
||||
},
|
||||
"reblogged": {
|
||||
"accessibilityLabel": "",
|
||||
"function": ""
|
||||
"accessibilityLabel": "このトゥートをブーストしますか?",
|
||||
"function": "トゥートをブースト"
|
||||
},
|
||||
"favourited": {
|
||||
"accessibilityLabel": "",
|
||||
"function": ""
|
||||
"accessibilityLabel": "このトゥートをお気に入りに追加",
|
||||
"function": "お気に入りトゥート"
|
||||
},
|
||||
"bookmarked": {
|
||||
"accessibilityLabel": "",
|
||||
"function": ""
|
||||
"accessibilityLabel": "このトゥートをブックマークに追加",
|
||||
"function": "ブックマークトゥート"
|
||||
}
|
||||
},
|
||||
"actionsUsers": {
|
||||
"reblogged_by": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityHint": "",
|
||||
"text": ""
|
||||
"accessibilityLabel": "{{count}}人がこのトゥートをブーストしました",
|
||||
"accessibilityHint": "タップしてユーザーを確認",
|
||||
"text": "$t(screenTabs:shared.users.statuses.reblogged_by)"
|
||||
},
|
||||
"favourited_by": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityHint": "",
|
||||
"text": ""
|
||||
"accessibilityLabel": "{{count}}人がこのトゥートをお気に入りしました",
|
||||
"accessibilityHint": "タップしてユーザーを確認",
|
||||
"text": "$t(screenTabs:shared.users.statuses.favourited_by)"
|
||||
},
|
||||
"history": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityHint": "",
|
||||
"text_one": "",
|
||||
"text_other": ""
|
||||
"accessibilityLabel": "このトゥートは{{count}}回編集されました",
|
||||
"accessibilityHint": "タップして全編集履歴を表示",
|
||||
"text_one": "{{count}}回 編集",
|
||||
"text_other": "{{count}}回 編集"
|
||||
}
|
||||
},
|
||||
"attachment": {
|
||||
"sensitive": {
|
||||
"button": ""
|
||||
"button": "閲覧注意のメディアを表示"
|
||||
},
|
||||
"unsupported": {
|
||||
"text": "",
|
||||
"button": ""
|
||||
"text": "読み込みエラー",
|
||||
"button": "リモートリンクを試す"
|
||||
}
|
||||
},
|
||||
"avatar": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityHint": ""
|
||||
"accessibilityLabel": "{{name}}のアバター",
|
||||
"accessibilityHint": "{{name}}のページに移動するにはタップしてください"
|
||||
},
|
||||
"content": {
|
||||
"expandHint": ""
|
||||
"expandHint": "内容を非表示にする"
|
||||
},
|
||||
"filtered": "",
|
||||
"fullConversation": "",
|
||||
"filtered": "フィルター済み",
|
||||
"fullConversation": "スレッドを読む",
|
||||
"translate": {
|
||||
"default": "",
|
||||
"succeed": "",
|
||||
"failed": "",
|
||||
"source_not_supported": "",
|
||||
"target_not_supported": ""
|
||||
"default": "翻訳",
|
||||
"succeed": "{{provider}} によって {{source}} から翻訳されました",
|
||||
"failed": "翻訳できませんでした",
|
||||
"source_not_supported": "サポートされていない言語のトゥートです",
|
||||
"target_not_supported": "ターゲットの言語の翻訳はサポートされてません"
|
||||
},
|
||||
"header": {
|
||||
"shared": {
|
||||
"account": {
|
||||
"name": {
|
||||
"accessibilityHint": ""
|
||||
"accessibilityHint": "ユーザーの表示名"
|
||||
},
|
||||
"account": {
|
||||
"accessibilityHint": ""
|
||||
"accessibilityHint": "ユーザーのアカウント名"
|
||||
}
|
||||
},
|
||||
"application": "",
|
||||
"application": "{{application}}",
|
||||
"edited": {
|
||||
"accessibilityLabel": ""
|
||||
"accessibilityLabel": "トゥートが編集されました"
|
||||
},
|
||||
"muted": {
|
||||
"accessibilityLabel": ""
|
||||
"accessibilityLabel": "トゥートがミュートされました"
|
||||
},
|
||||
"visibility": {
|
||||
"direct": {
|
||||
"accessibilityLabel": ""
|
||||
"accessibilityLabel": "トゥートはダイレクトメッセージです"
|
||||
},
|
||||
"private": {
|
||||
"accessibilityLabel": ""
|
||||
"accessibilityLabel": "トゥートはフォロワーのみに公開されます"
|
||||
}
|
||||
}
|
||||
},
|
||||
"conversation": {
|
||||
"withAccounts": "",
|
||||
"withAccounts": "@ ",
|
||||
"delete": {
|
||||
"function": ""
|
||||
"function": "ダイレクトメッセージを削除"
|
||||
}
|
||||
}
|
||||
},
|
||||
"poll": {
|
||||
"meta": {
|
||||
"button": {
|
||||
"vote": "",
|
||||
"refresh": ""
|
||||
"vote": "回答",
|
||||
"refresh": "再読み込み"
|
||||
},
|
||||
"count": {
|
||||
"voters_one": "",
|
||||
"voters_other": "",
|
||||
"votes_one": "",
|
||||
"votes_other": ""
|
||||
"voters_one": "{{count}} 人が投票",
|
||||
"voters_other": "{{count}} 人が投票",
|
||||
"votes_one": "{{count}} 票",
|
||||
"votes_other": "{{count}} 票"
|
||||
},
|
||||
"expiration": {
|
||||
"expired": "",
|
||||
"until": ""
|
||||
"expired": "終了",
|
||||
"until": "終了 <0 />"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,18 +1,18 @@
|
||||
{
|
||||
"screenshot": {
|
||||
"title": "",
|
||||
"message": "",
|
||||
"button": ""
|
||||
"title": "プライバシー保護",
|
||||
"message": "ユーザー名やアバターなど、他のユーザーを特定する情報は公開しないでください。",
|
||||
"button": "確定"
|
||||
},
|
||||
"localCorrupt": {
|
||||
"message": ""
|
||||
"message": "ログイン期限が切れました。もう一度ログインしてください。"
|
||||
},
|
||||
"pushError": {
|
||||
"message": "",
|
||||
"description": ""
|
||||
"message": "プッシュサービスのエラー",
|
||||
"description": "設定でプッシュ通知を再度有効にしてください"
|
||||
},
|
||||
"shareError": {
|
||||
"imageNotSupported": "",
|
||||
"videoNotSupported": ""
|
||||
"imageNotSupported": "画像ファイル形式 {{type}} はサポートされていません",
|
||||
"videoNotSupported": "動画ファイル形式 {{type}} はサポートされていません"
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"heading": "",
|
||||
"heading": "共有先",
|
||||
"content": {
|
||||
"select_account": ""
|
||||
"select_account": "アカウントを選択"
|
||||
}
|
||||
}
|
@ -1,19 +1,19 @@
|
||||
{
|
||||
"content": {
|
||||
"altText": {
|
||||
"heading": ""
|
||||
"heading": "代替テキスト"
|
||||
},
|
||||
"notificationsFilter": {
|
||||
"heading": "",
|
||||
"heading": "通知の種類を表示",
|
||||
"content": {
|
||||
"follow": "",
|
||||
"follow_request": "",
|
||||
"favourite": "",
|
||||
"reblog": "",
|
||||
"mention": "",
|
||||
"poll": "",
|
||||
"status": "",
|
||||
"update": ""
|
||||
"follow": "$t(screenTabs:me.push.follow.heading)",
|
||||
"follow_request": "フォローリクエスト",
|
||||
"favourite": "$t(screenTabs:me.push.favourite.heading)",
|
||||
"reblog": "$t(screenTabs:me.push.reblog.heading)",
|
||||
"mention": "$t(screenTabs:me.push.mention.heading)",
|
||||
"poll": "$t(screenTabs:me.push.poll.heading)",
|
||||
"status": "購読中のユーザーからのトゥート",
|
||||
"update": "リブログが編集されました"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
{
|
||||
"heading": "",
|
||||
"heading": "お知らせ",
|
||||
"content": {
|
||||
"published": "",
|
||||
"published": "公開 <0 />",
|
||||
"button": {
|
||||
"read": "",
|
||||
"unread": ""
|
||||
"read": "既読",
|
||||
"unread": "既読にする"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,35 +1,35 @@
|
||||
{
|
||||
"heading": {
|
||||
"left": {
|
||||
"button": "",
|
||||
"button": "キャンセル",
|
||||
"alert": {
|
||||
"title": "",
|
||||
"title": "編集をキャンセルしますか?",
|
||||
"buttons": {
|
||||
"save": "",
|
||||
"delete": "",
|
||||
"cancel": ""
|
||||
"save": "下書きを保存",
|
||||
"delete": "下書きを削除",
|
||||
"cancel": "キャンセル"
|
||||
}
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
"button": {
|
||||
"default": "",
|
||||
"conversation": "",
|
||||
"reply": "",
|
||||
"deleteEdit": "",
|
||||
"edit": "",
|
||||
"share": ""
|
||||
"default": "トゥート",
|
||||
"conversation": "DMをトゥート",
|
||||
"reply": "トゥートに返信",
|
||||
"deleteEdit": "トゥート",
|
||||
"edit": "トゥート",
|
||||
"share": "トゥート"
|
||||
},
|
||||
"alert": {
|
||||
"default": {
|
||||
"title": "",
|
||||
"button": ""
|
||||
"title": "トゥートに失敗しました",
|
||||
"button": "もう一度やり直す"
|
||||
},
|
||||
"removeReply": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"cancel": "",
|
||||
"confirm": ""
|
||||
"title": "返信されたトゥートが見つかりませんでした",
|
||||
"description": "返信されたトゥートが削除された可能性があります。参照から削除しますか?",
|
||||
"cancel": "$t(common:buttons.cancel)",
|
||||
"confirm": "参照を削除"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -37,143 +37,143 @@
|
||||
"content": {
|
||||
"root": {
|
||||
"header": {
|
||||
"postingAs": "",
|
||||
"postingAs": "@{{acct}}@{{domain}} としてトゥート",
|
||||
"spoilerInput": {
|
||||
"placeholder": ""
|
||||
"placeholder": "注意書き"
|
||||
},
|
||||
"textInput": {
|
||||
"placeholder": "",
|
||||
"placeholder": "今なにかんがえてるの?",
|
||||
"keyboardImage": {
|
||||
"exceedMaximum": {
|
||||
"title": "",
|
||||
"OK": ""
|
||||
"title": "アップロードできるメディアの数の上限に達しました",
|
||||
"OK": "$t(common:buttons.OK)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"attachments": {
|
||||
"sensitive": "",
|
||||
"sensitive": "メディアを閲覧注意にする",
|
||||
"remove": {
|
||||
"accessibilityLabel": ""
|
||||
"accessibilityLabel": "{{attachment}} のメディアを削除する"
|
||||
},
|
||||
"edit": {
|
||||
"accessibilityLabel": ""
|
||||
"accessibilityLabel": "{{attachment}} のメディアを編集する"
|
||||
},
|
||||
"upload": {
|
||||
"accessibilityLabel": ""
|
||||
"accessibilityLabel": "アップロードするメディアを追加する"
|
||||
}
|
||||
},
|
||||
"emojis": {
|
||||
"accessibilityHint": ""
|
||||
"accessibilityHint": "タップして絵文字を追加"
|
||||
},
|
||||
"poll": {
|
||||
"option": {
|
||||
"placeholder": {
|
||||
"accessibilityLabel": "",
|
||||
"single": "",
|
||||
"multiple": ""
|
||||
"accessibilityLabel": "投票オプション {{index}}",
|
||||
"single": "単一選択項目",
|
||||
"multiple": "複数選択項目"
|
||||
}
|
||||
},
|
||||
"quantity": {
|
||||
"reduce": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityHint": ""
|
||||
"accessibilityLabel": "投票の選択肢を {{amount}} に減らす",
|
||||
"accessibilityHint": "最小の投票数 {{amount}} に到達しました"
|
||||
},
|
||||
"increase": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityHint": ""
|
||||
"accessibilityLabel": "投票の選択肢を {{amount}} に増やす",
|
||||
"accessibilityHint": "最大の投票数 {{amount}} に到達しました"
|
||||
}
|
||||
},
|
||||
"multiple": {
|
||||
"heading": "",
|
||||
"heading": "投票タイプ",
|
||||
"options": {
|
||||
"single": "",
|
||||
"multiple": "",
|
||||
"cancel": ""
|
||||
"single": "単一選択式",
|
||||
"multiple": "複数選択式",
|
||||
"cancel": "$t(common:buttons.cancel)"
|
||||
}
|
||||
},
|
||||
"expiration": {
|
||||
"heading": "",
|
||||
"heading": "終了時間",
|
||||
"options": {
|
||||
"300": "",
|
||||
"1800": "",
|
||||
"3600": "",
|
||||
"21600": "",
|
||||
"86400": "",
|
||||
"259200": "",
|
||||
"604800": "",
|
||||
"cancel": ""
|
||||
"300": "5分",
|
||||
"1800": "30分",
|
||||
"3600": "1時間",
|
||||
"21600": "6時間",
|
||||
"86400": "1日",
|
||||
"259200": "3日",
|
||||
"604800": "7日",
|
||||
"cancel": "$t(common:buttons.cancel)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"attachment": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityHint": "",
|
||||
"accessibilityLabel": "メディアをアップロード",
|
||||
"accessibilityHint": "メディアをアップロードした場合、投票機能は無効化されます",
|
||||
"failed": {
|
||||
"alert": {
|
||||
"title": "",
|
||||
"button": ""
|
||||
"title": "アップロードに失敗",
|
||||
"button": "再試行"
|
||||
}
|
||||
}
|
||||
},
|
||||
"poll": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityHint": ""
|
||||
"accessibilityLabel": "投票を追加",
|
||||
"accessibilityHint": "投票がアクティブな場合、メディアのアップロードは無効化されます"
|
||||
},
|
||||
"visibility": {
|
||||
"accessibilityLabel": "",
|
||||
"title": "",
|
||||
"accessibilityLabel": "公開範囲は {{visibility}} です",
|
||||
"title": "公開範囲",
|
||||
"options": {
|
||||
"public": "",
|
||||
"unlisted": "",
|
||||
"private": "",
|
||||
"direct": "",
|
||||
"cancel": ""
|
||||
"public": "公開",
|
||||
"unlisted": "未収載",
|
||||
"private": "フォロワー限定",
|
||||
"direct": "ダイレクトメッセージ",
|
||||
"cancel": "$t(common:buttons.cancel)"
|
||||
}
|
||||
},
|
||||
"spoiler": {
|
||||
"accessibilityLabel": ""
|
||||
"accessibilityLabel": "スポイラーの設定"
|
||||
},
|
||||
"emoji": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityHint": ""
|
||||
"accessibilityLabel": "絵文字を追加",
|
||||
"accessibilityHint": "絵文字選択パネルを開き、横にスワイプしてページを変更します"
|
||||
}
|
||||
},
|
||||
"drafts_one": "",
|
||||
"drafts_other": ""
|
||||
"drafts_one": "下書き ({{count}})",
|
||||
"drafts_other": "下書き ({{count}})"
|
||||
},
|
||||
"editAttachment": {
|
||||
"header": {
|
||||
"title": "",
|
||||
"title": "メディアを編集",
|
||||
"right": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityLabel": "メディアの編集を保存",
|
||||
"failed": {
|
||||
"title": "",
|
||||
"button": ""
|
||||
"title": "編集に失敗",
|
||||
"button": "再試行"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"altText": {
|
||||
"heading": "",
|
||||
"placeholder": ""
|
||||
"heading": "閲覧が難しいユーザーへの説明",
|
||||
"placeholder": "目の不自由な人や視覚障がいを持つ人を含むより多くの人がメディアにアクセスできるようになる、代替テキスト(alt-text)とも呼ばれる説明をメディアに追加することができます。\n\n簡潔でありながらも、メディアの文脈を正確に理解できるような表現が良い説明です。"
|
||||
},
|
||||
"imageFocus": ""
|
||||
"imageFocus": "円形の枠をドラッグしてサムネイルの焦点にしたい場所を更新します"
|
||||
}
|
||||
},
|
||||
"draftsList": {
|
||||
"header": {
|
||||
"title": ""
|
||||
"title": "下書き"
|
||||
},
|
||||
"warning": "",
|
||||
"warning": "下書きは端末内のみに保存されます。下書きが不幸な出来事によって失われる可能性があります。そのため下書きを長期保存のために使用しないことを推奨します。",
|
||||
"content": {
|
||||
"accessibilityHint": "",
|
||||
"textEmpty": ""
|
||||
"accessibilityHint": "下書きを保存しました。タップしてこの下書きを編集します",
|
||||
"textEmpty": "コンテンツが空です"
|
||||
},
|
||||
"checkAttachment": ""
|
||||
"checkAttachment": "サーバー上にあるメディアを確認しています…"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,17 +1,17 @@
|
||||
{
|
||||
"content": {
|
||||
"actions": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityHint": ""
|
||||
"accessibilityLabel": "この画像に対するほかのアクション",
|
||||
"accessibilityHint": "この画像を保存または共有できます"
|
||||
},
|
||||
"options": {
|
||||
"save": "",
|
||||
"share": "",
|
||||
"cancel": ""
|
||||
"save": "画像を保存",
|
||||
"share": "画像を共有",
|
||||
"cancel": "$t(common:buttons.cancel)"
|
||||
},
|
||||
"save": {
|
||||
"succeed": "",
|
||||
"failed": ""
|
||||
"succeed": "画像を保存しました",
|
||||
"failed": "画像の保存に失敗しました"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,353 +1,353 @@
|
||||
{
|
||||
"tabs": {
|
||||
"local": {
|
||||
"name": ""
|
||||
"name": "ホーム"
|
||||
},
|
||||
"public": {
|
||||
"name": "",
|
||||
"segments": {
|
||||
"left": "",
|
||||
"right": ""
|
||||
"left": "連合",
|
||||
"right": "ローカル"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"name": ""
|
||||
"name": "通知"
|
||||
},
|
||||
"me": {
|
||||
"name": ""
|
||||
"name": "私について"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"search": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityHint": ""
|
||||
"accessibilityLabel": "検索",
|
||||
"accessibilityHint": "ハッシュタグ、ユーザー、トゥートを検索"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"filter": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityHint": ""
|
||||
"accessibilityLabel": "フィルター",
|
||||
"accessibilityHint": "表示される通知の種類をフィルターする"
|
||||
}
|
||||
},
|
||||
"me": {
|
||||
"stacks": {
|
||||
"bookmarks": {
|
||||
"name": ""
|
||||
"name": "ブックマーク"
|
||||
},
|
||||
"conversations": {
|
||||
"name": ""
|
||||
"name": "ダイレクトメッセージ"
|
||||
},
|
||||
"favourites": {
|
||||
"name": ""
|
||||
"name": "お気に入り"
|
||||
},
|
||||
"fontSize": {
|
||||
"name": ""
|
||||
"name": "トゥートのフォントサイズ"
|
||||
},
|
||||
"language": {
|
||||
"name": ""
|
||||
"name": "言語"
|
||||
},
|
||||
"lists": {
|
||||
"name": ""
|
||||
"name": "リスト"
|
||||
},
|
||||
"list": {
|
||||
"name": ""
|
||||
"name": "リスト: {{list}}"
|
||||
},
|
||||
"push": {
|
||||
"name": ""
|
||||
"name": "プッシュ通知"
|
||||
},
|
||||
"profile": {
|
||||
"name": ""
|
||||
"name": "プロフィールを編集"
|
||||
},
|
||||
"profileName": {
|
||||
"name": ""
|
||||
"name": "表示名を編集"
|
||||
},
|
||||
"profileNote": {
|
||||
"name": ""
|
||||
"name": "説明文を編集"
|
||||
},
|
||||
"profileFields": {
|
||||
"name": ""
|
||||
"name": "プロフィール補足情報を編集"
|
||||
},
|
||||
"settings": {
|
||||
"name": ""
|
||||
"name": "アプリの設定"
|
||||
},
|
||||
"webSettings": {
|
||||
"name": ""
|
||||
"name": "アカウントの設定"
|
||||
},
|
||||
"switch": {
|
||||
"name": ""
|
||||
"name": "アカウントを切り替える"
|
||||
}
|
||||
},
|
||||
"fontSize": {
|
||||
"demo": "",
|
||||
"demo": "<p>これはトゥートの例です。😊 フォントサイズを以下の選択肢から選択できます。<br /><br />この設定はトゥートのメインコンテンツにのみ影響し、他のフォントサイズには影響しません。</p>",
|
||||
"sizes": {
|
||||
"S": "",
|
||||
"M": "",
|
||||
"L": "",
|
||||
"XL": "",
|
||||
"XXL": ""
|
||||
"S": "小",
|
||||
"M": "中 (デフォルト)",
|
||||
"L": "大",
|
||||
"XL": "特大",
|
||||
"XXL": "最大"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"cancellation": {
|
||||
"title": "",
|
||||
"message": "",
|
||||
"title": "変更を破棄",
|
||||
"message": "変更は保存されていません。変更の保存を破棄しますか?",
|
||||
"buttons": {
|
||||
"cancel": "",
|
||||
"discard": ""
|
||||
"cancel": "$t(common:buttons.cancel)",
|
||||
"discard": "破棄"
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"succeed": "",
|
||||
"failed": ""
|
||||
"succeed": "{{type}} がアップデートされました",
|
||||
"failed": "{{type}} のアップデートに失敗しました。もういちどお試しください"
|
||||
},
|
||||
"root": {
|
||||
"name": {
|
||||
"title": ""
|
||||
"title": "表示名"
|
||||
},
|
||||
"avatar": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
"title": "アイコン",
|
||||
"description": "400x400pxまで縮小されます"
|
||||
},
|
||||
"header": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
"title": "ヘッダー",
|
||||
"description": "1500x1500pxまで縮小されます"
|
||||
},
|
||||
"note": {
|
||||
"title": ""
|
||||
"title": "プロフィール補足情報"
|
||||
},
|
||||
"fields": {
|
||||
"title": "",
|
||||
"total_one": "",
|
||||
"total_other": ""
|
||||
"title": "プロフィール補足情報",
|
||||
"total_one": "{{count}} つのフィールド",
|
||||
"total_other": "{{count}} つのフィールド"
|
||||
},
|
||||
"visibility": {
|
||||
"title": "",
|
||||
"title": "デフォルトの投稿の公開範囲",
|
||||
"options": {
|
||||
"public": "",
|
||||
"unlisted": "",
|
||||
"private": "",
|
||||
"cancel": ""
|
||||
"public": "公開",
|
||||
"unlisted": "未収載",
|
||||
"private": "フォロワー限定",
|
||||
"cancel": "$t(common:buttons.cancel)"
|
||||
}
|
||||
},
|
||||
"sensitive": {
|
||||
"title": ""
|
||||
"title": "メディアを常に閲覧注意として投稿する"
|
||||
},
|
||||
"lock": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
"title": "承認制アカウント",
|
||||
"description": "フォロワーを手動で承認する必要があります"
|
||||
},
|
||||
"bot": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
"title": "ボットアカウント",
|
||||
"description": "このアカウントを主に自動で動作するものとし、人による監視がされていない可能性があるものとします"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"group": "",
|
||||
"label": "",
|
||||
"content": ""
|
||||
"group": "グループ {{index}}",
|
||||
"label": "ラベル",
|
||||
"content": "内容"
|
||||
},
|
||||
"mediaSelectionFailed": ""
|
||||
"mediaSelectionFailed": "画像処理に失敗しました。もういちどお試しください。"
|
||||
},
|
||||
"push": {
|
||||
"notAvailable": "",
|
||||
"notAvailable": "あなたがお使いのデバイスでは tooot のプッシュ通知をサポートしていません",
|
||||
"enable": {
|
||||
"direct": "",
|
||||
"settings": ""
|
||||
"direct": "プッシュ通知を有効にする",
|
||||
"settings": "設定で有効にする"
|
||||
},
|
||||
"global": {
|
||||
"heading": "",
|
||||
"description": ""
|
||||
"heading": "{{acct}} の通知を有効にする",
|
||||
"description": "メッセージは tooot のサーバー経由で到達します"
|
||||
},
|
||||
"decode": {
|
||||
"heading": "",
|
||||
"description": ""
|
||||
"heading": "メッセージの詳細を表示",
|
||||
"description": "tooot のサーバーを介して到達するメッセージは暗号化されますが、サーバー上でメッセージを復号することを選択できます。サーバーのソースコードはオープンソースであり、ログを保存することはありません。"
|
||||
},
|
||||
"default": {
|
||||
"heading": ""
|
||||
"heading": "デフォルト"
|
||||
},
|
||||
"follow": {
|
||||
"heading": ""
|
||||
"heading": "新しいフォロワー"
|
||||
},
|
||||
"follow_request": {
|
||||
"heading": ""
|
||||
"heading": "フォローリクエスト"
|
||||
},
|
||||
"favourite": {
|
||||
"heading": ""
|
||||
"heading": "お気に入り"
|
||||
},
|
||||
"reblog": {
|
||||
"heading": ""
|
||||
"heading": "ブースト"
|
||||
},
|
||||
"mention": {
|
||||
"heading": ""
|
||||
"heading": "返信"
|
||||
},
|
||||
"poll": {
|
||||
"heading": ""
|
||||
"heading": "投票"
|
||||
},
|
||||
"status": {
|
||||
"heading": ""
|
||||
"heading": "購読したユーザーのトゥート"
|
||||
},
|
||||
"howitworks": ""
|
||||
"howitworks": "通知到達(routing)のしくみを学ぶ"
|
||||
},
|
||||
"root": {
|
||||
"announcements": {
|
||||
"content": {
|
||||
"unread": "",
|
||||
"read": "",
|
||||
"empty": ""
|
||||
"unread": "未読数: {{amount}}",
|
||||
"read": "すべて読みました",
|
||||
"empty": "ありません"
|
||||
}
|
||||
},
|
||||
"push": {
|
||||
"content": {
|
||||
"enabled": "",
|
||||
"disabled": ""
|
||||
"enabled": "有効",
|
||||
"disabled": "無効"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"title": ""
|
||||
"title": "最新バージョンへのアップデート"
|
||||
},
|
||||
"logout": {
|
||||
"button": "",
|
||||
"button": "ログアウト",
|
||||
"alert": {
|
||||
"title": "",
|
||||
"message": "",
|
||||
"title": "ログアウトしますか?",
|
||||
"message": "ログアウトすると、再度ログインする必要があります",
|
||||
"buttons": {
|
||||
"logout": "",
|
||||
"cancel": ""
|
||||
"logout": "ログアウト",
|
||||
"cancel": "$t(common:buttons.cancel)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"fontsize": {
|
||||
"heading": "",
|
||||
"heading": "$t(me.stacks.fontSize.name)",
|
||||
"content": {
|
||||
"S": "",
|
||||
"M": "",
|
||||
"L": "",
|
||||
"XL": "",
|
||||
"XXL": ""
|
||||
"S": "$t(me.fontSize.sizes.S)",
|
||||
"M": "$t(me.fontSize.sizes.M)",
|
||||
"L": "$t(me.fontSize.sizes.L)",
|
||||
"XL": "$t(me.fontSize.sizes.XL)",
|
||||
"XXL": "$t(me.fontSize.sizes.XXL)"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"heading": "",
|
||||
"heading": "$t(me.stacks.language.name)",
|
||||
"options": {
|
||||
"cancel": ""
|
||||
"cancel": "$t(common:buttons.cancel)"
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"heading": "",
|
||||
"heading": "外観",
|
||||
"options": {
|
||||
"auto": "",
|
||||
"light": "",
|
||||
"dark": "",
|
||||
"cancel": ""
|
||||
"auto": "システムの設定に従う",
|
||||
"light": "ライトモード",
|
||||
"dark": "ダークモード",
|
||||
"cancel": "$t(common:buttons.cancel)"
|
||||
}
|
||||
},
|
||||
"darkTheme": {
|
||||
"heading": "",
|
||||
"heading": "ダークテーマ",
|
||||
"options": {
|
||||
"lighter": "",
|
||||
"darker": "",
|
||||
"cancel": ""
|
||||
"lighter": "明るめ",
|
||||
"darker": "暗め",
|
||||
"cancel": "$t(common:buttons.cancel)"
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"heading": "",
|
||||
"heading": "リンクを開く",
|
||||
"options": {
|
||||
"internal": "",
|
||||
"external": "",
|
||||
"cancel": ""
|
||||
"internal": "アプリ内で開く",
|
||||
"external": "システムブラウザを使用する",
|
||||
"cancel": "$t(common:buttons.cancel)"
|
||||
}
|
||||
},
|
||||
"staticEmoji": {
|
||||
"heading": "",
|
||||
"description": ""
|
||||
"heading": "静的な絵文字リスト",
|
||||
"description": "絵文字リストを表示しているときにアプリが頻繁にクラッシュする場合、代わりにアニメーションが無効化された絵文字リストを使用してみてください。"
|
||||
},
|
||||
"feedback": {
|
||||
"heading": ""
|
||||
"heading": "機能リクエスト"
|
||||
},
|
||||
"support": {
|
||||
"heading": ""
|
||||
"heading": "tooot をサポート"
|
||||
},
|
||||
"review": {
|
||||
"heading": ""
|
||||
"heading": "tooot をレビュー"
|
||||
},
|
||||
"contact": {
|
||||
"heading": ""
|
||||
"heading": "tooot に関する連絡"
|
||||
},
|
||||
"analytics": {
|
||||
"heading": "",
|
||||
"description": ""
|
||||
"heading": "ソフトウェア改善への協力",
|
||||
"description": "ユーザーに関連しない情報のみ収集します"
|
||||
},
|
||||
"version": "",
|
||||
"instanceVersion": ""
|
||||
"version": "バージョン v{{version}}",
|
||||
"instanceVersion": "Mastodonのバージョン v{{version}}"
|
||||
},
|
||||
"switch": {
|
||||
"existing": "",
|
||||
"new": ""
|
||||
"existing": "ログイン済みのアカウント",
|
||||
"new": "インスタンスに新規ログイン"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"account": {
|
||||
"actions": {
|
||||
"accessibilityLabel": "",
|
||||
"accessibilityHint": ""
|
||||
"accessibilityLabel": "ユーザー {{user}} に対するアクション",
|
||||
"accessibilityHint": "このユーザーをミュート、ブロック、報告または共有できます"
|
||||
},
|
||||
"followed_by": "",
|
||||
"moved": "",
|
||||
"created_at": "",
|
||||
"followed_by": "はあなたをフォローしています",
|
||||
"moved": "ユーザーは引っ越ししました",
|
||||
"created_at": "登録日: {{date}}",
|
||||
"summary": {
|
||||
"statuses_count": "",
|
||||
"following_count": "",
|
||||
"followers_count": ""
|
||||
"statuses_count": "{{count}} 投稿",
|
||||
"following_count": "$t(shared.users.accounts.following)",
|
||||
"followers_count": "$t(shared.users.accounts.followers)"
|
||||
},
|
||||
"toots": {
|
||||
"default": "",
|
||||
"all": ""
|
||||
"default": "投稿",
|
||||
"all": "投稿と返信"
|
||||
}
|
||||
},
|
||||
"attachments": {
|
||||
"name": ""
|
||||
"name": "<0 /><1>\" のメディア</1>"
|
||||
},
|
||||
"search": {
|
||||
"header": {
|
||||
"prefix": "",
|
||||
"placeholder": ""
|
||||
"prefix": "検索",
|
||||
"placeholder": "検索したいことは…?"
|
||||
},
|
||||
"empty": {
|
||||
"general": "",
|
||||
"general": "検索キーワードを入力して<bold>$t(screenTabs:shared.search.sections.accounts)</bold>、<bold>$t(screenTabs:shared.search.sections.hashtags)</bold> または <bold>$t(screenTabs:shared.search.sections.statuses)</bold>を検索",
|
||||
"advanced": {
|
||||
"header": "",
|
||||
"header": "高度な検索のフォーマット",
|
||||
"example": {
|
||||
"account": "",
|
||||
"hashtag": "",
|
||||
"statusLink": "",
|
||||
"accountLink": ""
|
||||
"account": "$t(shared.search.header.prefix) $t(shared.search.sections.accounts)",
|
||||
"hashtag": "$t(shared.search.header.prefix) $t(shared.search.sections.hashtags)",
|
||||
"statusLink": "$t(shared.search.header.prefix) $t(shared.search.sections.statuses)",
|
||||
"accountLink": "$t(shared.search.header.prefix) $t(shared.search.sections.accounts)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"accounts": "",
|
||||
"hashtags": "",
|
||||
"statuses": ""
|
||||
"accounts": "ユーザー",
|
||||
"hashtags": "ハッシュタグ",
|
||||
"statuses": "投稿"
|
||||
},
|
||||
"notFound": ""
|
||||
"notFound": "{{type}} <bold>{{searchTerm}}</bold> は見つかりませんでした"
|
||||
},
|
||||
"toot": {
|
||||
"name": ""
|
||||
"name": "スレッド"
|
||||
},
|
||||
"users": {
|
||||
"accounts": {
|
||||
"following": "",
|
||||
"followers": ""
|
||||
"following": "{{count}} フォロー",
|
||||
"followers": "{{count}} フォロワー"
|
||||
},
|
||||
"statuses": {
|
||||
"reblogged_by": "",
|
||||
"favourited_by": ""
|
||||
"reblogged_by": "{{count}} ブースト",
|
||||
"favourited_by": "{{count}} お気に入り"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"name": ""
|
||||
"name": "編集履歴"
|
||||
}
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ const LOCALES = {
|
||||
de: 'Deutsch',
|
||||
en: 'English',
|
||||
it: 'Italiano',
|
||||
ja: '日本語',
|
||||
ko: '한국어',
|
||||
'pt-BR': 'Português (Brasil)',
|
||||
vi: 'Tiếng Việt',
|
||||
|
@ -16,7 +16,7 @@
|
||||
},
|
||||
"copy": {
|
||||
"action": "",
|
||||
"succeed": ""
|
||||
"succeed": "Copiado"
|
||||
},
|
||||
"instance": {
|
||||
"title": "Ação da Instância",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"heading": "",
|
||||
"heading": "Compartilhar com ...",
|
||||
"content": {
|
||||
"select_account": ""
|
||||
"select_account": "Selecione a conta"
|
||||
}
|
||||
}
|
@ -5,13 +5,7 @@ import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import { chunk, forEach, groupBy, sortBy } from 'lodash'
|
||||
import React, { useContext, useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
AccessibilityInfo,
|
||||
findNodeHandle,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
View
|
||||
} from 'react-native'
|
||||
import { AccessibilityInfo, findNodeHandle, FlatList, StyleSheet, View } from 'react-native'
|
||||
import { Circle } from 'react-native-animated-spinkit'
|
||||
import ComposeActions from './Root/Actions'
|
||||
import ComposePosting from './Posting'
|
||||
@ -79,8 +73,7 @@ const ComposeRoot = React.memo(
|
||||
|
||||
const { isFetching, data, refetch } = useSearchQuery({
|
||||
type:
|
||||
composeState.tag?.type === 'accounts' ||
|
||||
composeState.tag?.type === 'hashtags'
|
||||
composeState.tag?.type === 'accounts' || composeState.tag?.type === 'hashtags'
|
||||
? composeState.tag.type
|
||||
: undefined,
|
||||
term: composeState.tag?.text.substring(1),
|
||||
@ -89,8 +82,7 @@ const ComposeRoot = React.memo(
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(composeState.tag?.type === 'accounts' ||
|
||||
composeState.tag?.type === 'hashtags') &&
|
||||
(composeState.tag?.type === 'accounts' || composeState.tag?.type === 'hashtags') &&
|
||||
composeState.tag?.text
|
||||
) {
|
||||
refetch()
|
||||
@ -106,10 +98,8 @@ const ComposeRoot = React.memo(
|
||||
title: string
|
||||
data: Pick<Mastodon.Emoji, 'shortcode' | 'url' | 'static_url'>[][]
|
||||
}[] = []
|
||||
forEach(
|
||||
groupBy(sortBy(emojisData, ['category', 'shortcode']), 'category'),
|
||||
(value, key) =>
|
||||
sortedEmojis.push({ title: key, data: chunk(value, 5) })
|
||||
forEach(groupBy(sortBy(emojisData, ['category', 'shortcode']), 'category'), (value, key) =>
|
||||
sortedEmojis.push({ title: key, data: chunk(value, 5) })
|
||||
)
|
||||
if (frequentEmojis.length) {
|
||||
sortedEmojis.unshift({
|
||||
@ -132,15 +122,22 @@ const ComposeRoot = React.memo(
|
||||
if (isFetching) {
|
||||
return (
|
||||
<View key='listEmpty' style={styles.loading}>
|
||||
<Circle
|
||||
size={StyleConstants.Font.Size.M * 1.25}
|
||||
color={colors.secondary}
|
||||
/>
|
||||
<Circle size={StyleConstants.Font.Size.M * 1.25} color={colors.secondary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}, [isFetching])
|
||||
|
||||
const Footer = useMemo(
|
||||
() => (
|
||||
<ComposeRootFooter
|
||||
accessibleRefAttachments={accessibleRefAttachments}
|
||||
accessibleRefEmojis={accessibleRefEmojis}
|
||||
/>
|
||||
),
|
||||
[accessibleRefAttachments.current, accessibleRefEmojis.current]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
<FlatList
|
||||
@ -154,12 +151,7 @@ const ComposeRoot = React.memo(
|
||||
ListEmptyComponent={listEmpty}
|
||||
keyboardShouldPersistTaps='always'
|
||||
ListHeaderComponent={ComposeRootHeader}
|
||||
ListFooterComponent={() => (
|
||||
<ComposeRootFooter
|
||||
accessibleRefAttachments={accessibleRefAttachments}
|
||||
accessibleRefEmojis={accessibleRefEmojis}
|
||||
/>
|
||||
)}
|
||||
ListFooterComponent={Footer}
|
||||
ItemSeparatorComponent={ComponentSeparator}
|
||||
// @ts-ignore
|
||||
data={data ? data[composeState.tag?.type] : undefined}
|
||||
|
115
src/screens/Compose/utils/types.d.ts
vendored
115
src/screens/Compose/utils/types.d.ts
vendored
@ -43,20 +43,17 @@ export type ComposeState = {
|
||||
emoji: {
|
||||
active: boolean
|
||||
emojis:
|
||||
| {
|
||||
title: string
|
||||
data: Pick<Mastodon.Emoji, 'shortcode' | 'url' | 'static_url'>[][]
|
||||
}[]
|
||||
| undefined
|
||||
| {
|
||||
title: string
|
||||
data: Pick<Mastodon.Emoji, 'shortcode' | 'url' | 'static_url'>[][]
|
||||
}[]
|
||||
| undefined
|
||||
}
|
||||
poll: {
|
||||
active: boolean
|
||||
total: number
|
||||
options: {
|
||||
'0': string | undefined
|
||||
'1': string | undefined
|
||||
'2': string | undefined
|
||||
'3': string | undefined
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
multiple: boolean
|
||||
expire: '300' | '1800' | '3600' | '21600' | '86400' | '259200' | '604800'
|
||||
@ -76,69 +73,69 @@ export type ComposeState = {
|
||||
|
||||
export type ComposeAction =
|
||||
| {
|
||||
type: 'loadDraft'
|
||||
payload: ComposeStateDraft
|
||||
}
|
||||
type: 'loadDraft'
|
||||
payload: ComposeStateDraft
|
||||
}
|
||||
| {
|
||||
type: 'dirty'
|
||||
payload: ComposeState['dirty']
|
||||
}
|
||||
type: 'dirty'
|
||||
payload: ComposeState['dirty']
|
||||
}
|
||||
| {
|
||||
type: 'posting'
|
||||
payload: ComposeState['posting']
|
||||
}
|
||||
type: 'posting'
|
||||
payload: ComposeState['posting']
|
||||
}
|
||||
| {
|
||||
type: 'spoiler'
|
||||
payload: Partial<ComposeState['spoiler']>
|
||||
}
|
||||
type: 'spoiler'
|
||||
payload: Partial<ComposeState['spoiler']>
|
||||
}
|
||||
| {
|
||||
type: 'text'
|
||||
payload: Partial<ComposeState['text']>
|
||||
}
|
||||
type: 'text'
|
||||
payload: Partial<ComposeState['text']>
|
||||
}
|
||||
| {
|
||||
type: 'tag'
|
||||
payload: ComposeState['tag']
|
||||
}
|
||||
type: 'tag'
|
||||
payload: ComposeState['tag']
|
||||
}
|
||||
| {
|
||||
type: 'emoji'
|
||||
payload: ComposeState['emoji']
|
||||
}
|
||||
type: 'emoji'
|
||||
payload: ComposeState['emoji']
|
||||
}
|
||||
| {
|
||||
type: 'poll'
|
||||
payload: Partial<ComposeState['poll']>
|
||||
}
|
||||
type: 'poll'
|
||||
payload: Partial<ComposeState['poll']>
|
||||
}
|
||||
| {
|
||||
type: 'attachments/sensitive'
|
||||
payload: Pick<ComposeState['attachments'], 'sensitive'>
|
||||
}
|
||||
type: 'attachments/sensitive'
|
||||
payload: Pick<ComposeState['attachments'], 'sensitive'>
|
||||
}
|
||||
| {
|
||||
type: 'attachment/upload/start'
|
||||
payload: Pick<ExtendedAttachment, 'local' | 'uploading'>
|
||||
}
|
||||
type: 'attachment/upload/start'
|
||||
payload: Pick<ExtendedAttachment, 'local' | 'uploading'>
|
||||
}
|
||||
| {
|
||||
type: 'attachment/upload/end'
|
||||
payload: { remote: Mastodon.Attachment; local: Asset }
|
||||
}
|
||||
type: 'attachment/upload/end'
|
||||
payload: { remote: Mastodon.Attachment; local: Asset }
|
||||
}
|
||||
| {
|
||||
type: 'attachment/upload/fail'
|
||||
payload: ExtendedAttachment['local']['hash']
|
||||
}
|
||||
type: 'attachment/upload/fail'
|
||||
payload: ExtendedAttachment['local']['hash']
|
||||
}
|
||||
| {
|
||||
type: 'attachment/delete'
|
||||
payload: NonNullable<ExtendedAttachment['remote']>['id']
|
||||
}
|
||||
type: 'attachment/delete'
|
||||
payload: NonNullable<ExtendedAttachment['remote']>['id']
|
||||
}
|
||||
| {
|
||||
type: 'attachment/edit'
|
||||
payload: ExtendedAttachment['remote']
|
||||
}
|
||||
type: 'attachment/edit'
|
||||
payload: ExtendedAttachment['remote']
|
||||
}
|
||||
| {
|
||||
type: 'visibility'
|
||||
payload: ComposeState['visibility']
|
||||
}
|
||||
type: 'visibility'
|
||||
payload: ComposeState['visibility']
|
||||
}
|
||||
| {
|
||||
type: 'textInputFocus'
|
||||
payload: Partial<ComposeState['textInputFocus']>
|
||||
}
|
||||
type: 'textInputFocus'
|
||||
payload: Partial<ComposeState['textInputFocus']>
|
||||
}
|
||||
| {
|
||||
type: 'removeReply'
|
||||
}
|
||||
type: 'removeReply'
|
||||
}
|
||||
|
@ -1,7 +0,0 @@
|
||||
import * as rn from "react-native";
|
||||
|
||||
declare module "react-native" {
|
||||
class VirtualizedList<ItemT> extends React.Component<
|
||||
VirtualizedListProps<ItemT>
|
||||
> {}
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
export type Dimensions = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type Position = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
@ -1,132 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import { RootStackParamList } from '@utils/navigation/navigators'
|
||||
import React, { ComponentType, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
StyleSheet,
|
||||
View,
|
||||
VirtualizedList
|
||||
} from 'react-native'
|
||||
import ImageItem from './components/ImageItem'
|
||||
import useAnimatedComponents from './hooks/useAnimatedComponents'
|
||||
import useImageIndexChange from './hooks/useImageIndexChange'
|
||||
import useRequestClose from './hooks/useRequestClose'
|
||||
|
||||
type Props = {
|
||||
images: RootStackParamList['Screen-ImagesViewer']['imageUrls']
|
||||
imageIndex: number
|
||||
onRequestClose: () => void
|
||||
onLongPress?: (
|
||||
image: RootStackParamList['Screen-ImagesViewer']['imageUrls'][0]
|
||||
) => void
|
||||
onImageIndexChange?: (imageIndex: number) => void
|
||||
backgroundColor?: string
|
||||
swipeToCloseEnabled?: boolean
|
||||
delayLongPress?: number
|
||||
HeaderComponent: ComponentType<{ imageIndex: number }>
|
||||
}
|
||||
|
||||
const DEFAULT_BG_COLOR = '#000'
|
||||
const DEFAULT_DELAY_LONG_PRESS = 800
|
||||
const SCREEN = Dimensions.get('screen')
|
||||
const SCREEN_WIDTH = SCREEN.width
|
||||
|
||||
function ImageViewer ({
|
||||
images,
|
||||
imageIndex,
|
||||
onRequestClose,
|
||||
onLongPress = () => {},
|
||||
onImageIndexChange,
|
||||
backgroundColor = DEFAULT_BG_COLOR,
|
||||
swipeToCloseEnabled,
|
||||
delayLongPress = DEFAULT_DELAY_LONG_PRESS,
|
||||
HeaderComponent
|
||||
}: Props) {
|
||||
const imageList = React.createRef<
|
||||
VirtualizedList<RootStackParamList['Screen-ImagesViewer']['imageUrls'][0]>
|
||||
>()
|
||||
const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose)
|
||||
const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN)
|
||||
const [headerTransform, toggleBarsVisible] = useAnimatedComponents()
|
||||
|
||||
useEffect(() => {
|
||||
if (onImageIndexChange) {
|
||||
onImageIndexChange(currentImageIndex)
|
||||
}
|
||||
}, [currentImageIndex])
|
||||
|
||||
const onZoom = useCallback(
|
||||
(isScaled: boolean) => {
|
||||
// @ts-ignore
|
||||
imageList?.current?.setNativeProps({ scrollEnabled: !isScaled })
|
||||
toggleBarsVisible(!isScaled)
|
||||
},
|
||||
[imageList]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { opacity, backgroundColor }]}>
|
||||
<Animated.View style={[styles.header, { transform: headerTransform }]}>
|
||||
{React.createElement(HeaderComponent, {
|
||||
imageIndex: currentImageIndex
|
||||
})}
|
||||
</Animated.View>
|
||||
<VirtualizedList
|
||||
ref={imageList}
|
||||
data={images}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
windowSize={2}
|
||||
initialNumToRender={1}
|
||||
maxToRenderPerBatch={1}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
initialScrollIndex={
|
||||
imageIndex > images.length - 1 ? images.length - 1 : imageIndex
|
||||
}
|
||||
getItem={(_, index) => images[index]}
|
||||
getItemCount={() => images.length}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: SCREEN_WIDTH,
|
||||
offset: SCREEN_WIDTH * index,
|
||||
index
|
||||
})}
|
||||
renderItem={({ item: imageSrc }) => (
|
||||
<ImageItem
|
||||
onZoom={onZoom}
|
||||
imageSrc={imageSrc}
|
||||
onRequestClose={onRequestCloseEnhanced}
|
||||
onLongPress={onLongPress}
|
||||
delayLongPress={delayLongPress}
|
||||
swipeToCloseEnabled={swipeToCloseEnabled}
|
||||
/>
|
||||
)}
|
||||
onMomentumScrollEnd={onScroll}
|
||||
keyExtractor={imageSrc => imageSrc.url}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000'
|
||||
},
|
||||
header: {
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
top: 0
|
||||
}
|
||||
})
|
||||
|
||||
export default ImageViewer
|
@ -1,123 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import GracefullyImage from '@components/GracefullyImage'
|
||||
import { RootStackParamList } from '@utils/navigation/navigators'
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { Animated, Dimensions, StyleSheet } from 'react-native'
|
||||
import usePanResponder from '../hooks/usePanResponder'
|
||||
import { getImageStyles, getImageTransform } from '../utils'
|
||||
|
||||
const SCREEN = Dimensions.get('window')
|
||||
const SCREEN_WIDTH = SCREEN.width
|
||||
const SCREEN_HEIGHT = SCREEN.height
|
||||
|
||||
type Props = {
|
||||
imageSrc: RootStackParamList['Screen-ImagesViewer']['imageUrls'][0]
|
||||
onRequestClose: () => void
|
||||
onZoom: (isZoomed: boolean) => void
|
||||
onLongPress: (
|
||||
image: RootStackParamList['Screen-ImagesViewer']['imageUrls'][0]
|
||||
) => void
|
||||
delayLongPress: number
|
||||
swipeToCloseEnabled?: boolean
|
||||
doubleTapToZoomEnabled?: boolean
|
||||
}
|
||||
|
||||
const ImageItem = ({
|
||||
imageSrc,
|
||||
onZoom,
|
||||
onRequestClose,
|
||||
onLongPress,
|
||||
delayLongPress,
|
||||
doubleTapToZoomEnabled = true
|
||||
}: Props) => {
|
||||
const imageContainer = React.createRef<any>()
|
||||
const [imageDimensions, setImageDimensions] = useState({
|
||||
width: imageSrc.width || 0,
|
||||
height: imageSrc.height || 0
|
||||
})
|
||||
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
||||
|
||||
const onZoomPerformed = (isZoomed: boolean) => {
|
||||
onZoom(isZoomed)
|
||||
if (imageContainer?.current) {
|
||||
// @ts-ignore
|
||||
imageContainer.current.setNativeProps({
|
||||
scrollEnabled: !isZoomed
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onLongPressHandler = useCallback(() => {
|
||||
onLongPress(imageSrc)
|
||||
}, [imageSrc, onLongPress])
|
||||
|
||||
const [panHandlers, scaleValue, translateValue] = usePanResponder({
|
||||
initialScale: scale || 1,
|
||||
initialTranslate: translate || { x: 0, y: 0 },
|
||||
onZoom: onZoomPerformed,
|
||||
doubleTapToZoomEnabled,
|
||||
onLongPress: onLongPressHandler,
|
||||
delayLongPress,
|
||||
onRequestClose
|
||||
})
|
||||
|
||||
const imagesStyles = getImageStyles(
|
||||
imageDimensions,
|
||||
translateValue,
|
||||
scaleValue
|
||||
)
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={imageContainer}
|
||||
style={styles.listItem}
|
||||
pagingEnabled
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.imageScrollContainer}
|
||||
scrollEnabled={false}
|
||||
>
|
||||
<Animated.View
|
||||
{...panHandlers}
|
||||
style={imagesStyles}
|
||||
children={
|
||||
<GracefullyImage
|
||||
uri={{
|
||||
preview: imageSrc.preview_url,
|
||||
original: imageSrc.url,
|
||||
remote: imageSrc.remote_url
|
||||
}}
|
||||
{...((!imageSrc.width || !imageSrc.height) && {
|
||||
setImageDimensions
|
||||
})}
|
||||
style={{ flex: 1 }}
|
||||
imageStyle={{
|
||||
flex: 1,
|
||||
resizeMode: 'stretch'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Animated.ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listItem: {
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT
|
||||
},
|
||||
imageScrollContainer: {
|
||||
height: SCREEN_HEIGHT * 2
|
||||
}
|
||||
})
|
||||
|
||||
export default React.memo(ImageItem)
|
@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { GestureResponderEvent } from "react-native";
|
||||
import { ImageSource } from "../@types";
|
||||
|
||||
declare type Props = {
|
||||
imageSrc: ImageSource;
|
||||
onRequestClose: () => void;
|
||||
onZoom: (isZoomed: boolean) => void;
|
||||
onLongPress: (image: ImageSource) => void;
|
||||
delayLongPress: number;
|
||||
swipeToCloseEnabled?: boolean;
|
||||
doubleTapToZoomEnabled?: boolean;
|
||||
};
|
||||
|
||||
declare const _default: React.MemoExoticComponent<({
|
||||
imageSrc,
|
||||
onZoom,
|
||||
onRequestClose,
|
||||
onLongPress,
|
||||
delayLongPress,
|
||||
swipeToCloseEnabled,
|
||||
}: Props) => JSX.Element>;
|
||||
|
||||
export default _default;
|
@ -1,177 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import GracefullyImage from '@components/GracefullyImage'
|
||||
import { RootStackParamList } from '@utils/navigation/navigators'
|
||||
import React, { createRef, useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
ScrollView,
|
||||
StyleSheet
|
||||
} from 'react-native'
|
||||
import {
|
||||
LongPressGestureHandler,
|
||||
State,
|
||||
TapGestureHandler
|
||||
} from 'react-native-gesture-handler'
|
||||
import useDoubleTapToZoom from '../hooks/useDoubleTapToZoom'
|
||||
import { getImageStyles, getImageTransform } from '../utils'
|
||||
|
||||
const SWIPE_CLOSE_OFFSET = 75
|
||||
const SWIPE_CLOSE_VELOCITY = 0.55
|
||||
const SCREEN = Dimensions.get('screen')
|
||||
const SCREEN_WIDTH = SCREEN.width
|
||||
const SCREEN_HEIGHT = SCREEN.height
|
||||
|
||||
type Props = {
|
||||
imageSrc: RootStackParamList['Screen-ImagesViewer']['imageUrls'][0]
|
||||
onRequestClose: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
onLongPress: (
|
||||
image: RootStackParamList['Screen-ImagesViewer']['imageUrls'][0]
|
||||
) => void
|
||||
swipeToCloseEnabled?: boolean
|
||||
}
|
||||
|
||||
const doubleTap = createRef()
|
||||
|
||||
const ImageItem = ({
|
||||
imageSrc,
|
||||
onZoom,
|
||||
onRequestClose,
|
||||
onLongPress,
|
||||
swipeToCloseEnabled = true
|
||||
}: Props) => {
|
||||
const scrollViewRef = useRef<ScrollView>(null)
|
||||
const [scaled, setScaled] = useState(false)
|
||||
const [imageDimensions, setImageDimensions] = useState({
|
||||
width: imageSrc.width || 1,
|
||||
height: imageSrc.height || 1
|
||||
})
|
||||
const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN)
|
||||
|
||||
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
||||
const scrollValueY = new Animated.Value(0)
|
||||
const scaleValue = new Animated.Value(scale || 1)
|
||||
const translateValue = new Animated.ValueXY(translate)
|
||||
const maxScale = scale && scale > 0 ? Math.max(1 / scale, 1) : 1
|
||||
|
||||
const imageOpacity = scrollValueY.interpolate({
|
||||
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
||||
outputRange: [0.5, 1, 0.5]
|
||||
})
|
||||
const imagesStyles = getImageStyles(
|
||||
imageDimensions,
|
||||
translateValue,
|
||||
scaleValue
|
||||
)
|
||||
const imageStylesWithOpacity = { ...imagesStyles, opacity: imageOpacity }
|
||||
|
||||
const onScrollEndDrag = useCallback(
|
||||
({ nativeEvent }: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const velocityY = nativeEvent?.velocity?.y ?? 0
|
||||
const scaled = nativeEvent?.zoomScale > 1
|
||||
|
||||
onZoom(scaled)
|
||||
setScaled(scaled)
|
||||
|
||||
if (
|
||||
!scaled &&
|
||||
swipeToCloseEnabled &&
|
||||
Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY
|
||||
) {
|
||||
onRequestClose()
|
||||
}
|
||||
},
|
||||
[scaled]
|
||||
)
|
||||
|
||||
const onScroll = ({
|
||||
nativeEvent
|
||||
}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
||||
|
||||
if (nativeEvent?.zoomScale > 1) {
|
||||
return
|
||||
}
|
||||
|
||||
scrollValueY.setValue(offsetY)
|
||||
}
|
||||
|
||||
return (
|
||||
<LongPressGestureHandler
|
||||
onHandlerStateChange={({ nativeEvent }) => {
|
||||
if (nativeEvent.state === State.ACTIVE) {
|
||||
onLongPress(imageSrc)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TapGestureHandler
|
||||
onHandlerStateChange={({ nativeEvent }) =>
|
||||
nativeEvent.state === State.ACTIVE && onRequestClose()
|
||||
}
|
||||
waitFor={doubleTap}
|
||||
>
|
||||
<TapGestureHandler
|
||||
ref={doubleTap}
|
||||
onHandlerStateChange={handleDoubleTap}
|
||||
numberOfTaps={2}
|
||||
>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.listItem}
|
||||
pinchGestureEnabled
|
||||
nestedScrollEnabled={true}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
maximumZoomScale={maxScale}
|
||||
contentContainerStyle={styles.imageScrollContainer}
|
||||
scrollEnabled={swipeToCloseEnabled}
|
||||
onScrollEndDrag={onScrollEndDrag}
|
||||
scrollEventThrottle={1}
|
||||
{...(swipeToCloseEnabled && {
|
||||
onScroll
|
||||
})}
|
||||
>
|
||||
<Animated.View
|
||||
style={imageStylesWithOpacity}
|
||||
children={
|
||||
<GracefullyImage
|
||||
uri={{
|
||||
preview: imageSrc.preview_url,
|
||||
original: imageSrc.url,
|
||||
remote: imageSrc.remote_url
|
||||
}}
|
||||
{...((!imageSrc.width || !imageSrc.height) && {
|
||||
setImageDimensions
|
||||
})}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
</TapGestureHandler>
|
||||
</TapGestureHandler>
|
||||
</LongPressGestureHandler>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listItem: {
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT
|
||||
},
|
||||
imageScrollContainer: {
|
||||
height: SCREEN_HEIGHT
|
||||
}
|
||||
})
|
||||
|
||||
export default React.memo(ImageItem)
|
@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import { Animated } from 'react-native'
|
||||
|
||||
const INITIAL_POSITION = { x: 0, y: 0 }
|
||||
const ANIMATION_CONFIG = {
|
||||
duration: 200,
|
||||
useNativeDriver: true
|
||||
}
|
||||
|
||||
const useAnimatedComponents = () => {
|
||||
const headerTranslate = new Animated.ValueXY(INITIAL_POSITION)
|
||||
|
||||
const toggleVisible = (isVisible: boolean) => {
|
||||
if (isVisible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(headerTranslate.y, { ...ANIMATION_CONFIG, toValue: 0 })
|
||||
]).start()
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(headerTranslate.y, {
|
||||
...ANIMATION_CONFIG,
|
||||
toValue: -300
|
||||
})
|
||||
]).start()
|
||||
}
|
||||
}
|
||||
|
||||
const headerTransform = headerTranslate.getTranslateTransform()
|
||||
|
||||
return [headerTransform, toggleVisible] as const
|
||||
}
|
||||
|
||||
export default useAnimatedComponents
|
@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { ScrollView } from 'react-native'
|
||||
import {
|
||||
HandlerStateChangeEvent,
|
||||
State,
|
||||
TapGestureHandlerEventPayload
|
||||
} from 'react-native-gesture-handler'
|
||||
import { Dimensions } from '../@types'
|
||||
|
||||
/**
|
||||
* This is iOS only.
|
||||
* Same functionality for Android implemented inside usePanResponder hook.
|
||||
*/
|
||||
function useDoubleTapToZoom (
|
||||
scrollViewRef: React.RefObject<ScrollView>,
|
||||
scaled: boolean,
|
||||
screen: Dimensions
|
||||
) {
|
||||
const handleDoubleTap = useCallback(
|
||||
({
|
||||
nativeEvent
|
||||
}: HandlerStateChangeEvent<TapGestureHandlerEventPayload>) => {
|
||||
if (nativeEvent.state === State.ACTIVE) {
|
||||
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
|
||||
|
||||
const { absoluteX, absoluteY } = nativeEvent
|
||||
let targetX = 0
|
||||
let targetY = 0
|
||||
let targetWidth = screen.width
|
||||
let targetHeight = screen.height
|
||||
|
||||
// Zooming in
|
||||
// TODO: Add more precise calculation of targetX, targetY based on touch
|
||||
if (!scaled) {
|
||||
targetX = absoluteX / 2
|
||||
targetY = absoluteY / 2
|
||||
targetWidth = screen.width / 2
|
||||
targetHeight = screen.height / 2
|
||||
}
|
||||
|
||||
scrollResponderRef?.scrollResponderZoomTo({
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
animated: true
|
||||
})
|
||||
}
|
||||
},
|
||||
[scaled]
|
||||
)
|
||||
|
||||
return handleDoubleTap
|
||||
}
|
||||
|
||||
export default useDoubleTapToZoom
|
@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { NativeSyntheticEvent, NativeScrollEvent } from 'react-native'
|
||||
import { Dimensions } from '../@types'
|
||||
|
||||
const useImageIndexChange = (imageIndex: number, screen: Dimensions) => {
|
||||
const [currentImageIndex, setImageIndex] = useState(imageIndex)
|
||||
const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const {
|
||||
nativeEvent: {
|
||||
contentOffset: { x: scrollX }
|
||||
}
|
||||
} = event
|
||||
|
||||
if (screen.width) {
|
||||
const nextIndex = Math.round(scrollX / screen.width)
|
||||
setImageIndex(nextIndex < 0 ? 0 : nextIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return [currentImageIndex, onScroll] as const
|
||||
}
|
||||
|
||||
export default useImageIndexChange
|
@ -1,406 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import { useMemo, useEffect } from 'react'
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
GestureResponderEvent,
|
||||
GestureResponderHandlers,
|
||||
NativeTouchEvent,
|
||||
PanResponderGestureState
|
||||
} from 'react-native'
|
||||
import { Position } from '../@types'
|
||||
import {
|
||||
createPanResponder,
|
||||
getDistanceBetweenTouches,
|
||||
getImageTranslate,
|
||||
getImageDimensionsByTranslate
|
||||
} from '../utils'
|
||||
|
||||
const SCREEN = Dimensions.get('window')
|
||||
const SCREEN_WIDTH = SCREEN.width
|
||||
const SCREEN_HEIGHT = SCREEN.height
|
||||
const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT)
|
||||
|
||||
const SCALE_MAX = 1
|
||||
const DOUBLE_TAP_DELAY = 300
|
||||
const OUT_BOUND_MULTIPLIER = 0.75
|
||||
|
||||
type Props = {
|
||||
initialScale: number
|
||||
initialTranslate: Position
|
||||
onZoom: (isZoomed: boolean) => void
|
||||
doubleTapToZoomEnabled: boolean
|
||||
onLongPress: () => void
|
||||
delayLongPress: number
|
||||
onRequestClose: () => void
|
||||
}
|
||||
|
||||
const usePanResponder = ({
|
||||
initialScale,
|
||||
initialTranslate,
|
||||
onZoom,
|
||||
doubleTapToZoomEnabled,
|
||||
onLongPress,
|
||||
delayLongPress,
|
||||
onRequestClose
|
||||
}: Props): Readonly<
|
||||
[GestureResponderHandlers, Animated.Value, Animated.ValueXY]
|
||||
> => {
|
||||
let numberInitialTouches = 1
|
||||
let initialTouches: NativeTouchEvent[] = []
|
||||
let currentScale = initialScale
|
||||
let currentTranslate = initialTranslate
|
||||
let tmpScale = 0
|
||||
let tmpTranslate: Position | null = null
|
||||
let isDoubleTapPerformed = false
|
||||
let lastTapTS: number | null = null
|
||||
let timer: number | null = null
|
||||
let longPressHandlerRef: number | null = null
|
||||
|
||||
const meaningfulShift = MIN_DIMENSION * 0.01
|
||||
const scaleValue = new Animated.Value(initialScale)
|
||||
const translateValue = new Animated.ValueXY(initialTranslate)
|
||||
|
||||
const imageDimensions = getImageDimensionsByTranslate(
|
||||
initialTranslate,
|
||||
SCREEN
|
||||
)
|
||||
|
||||
const getBounds = (scale: number) => {
|
||||
const scaledImageDimensions = {
|
||||
width: imageDimensions.width * scale,
|
||||
height: imageDimensions.height * scale
|
||||
}
|
||||
const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN)
|
||||
|
||||
const left = initialTranslate.x - translateDelta.x
|
||||
const right = left - (scaledImageDimensions.width - SCREEN.width)
|
||||
const top = initialTranslate.y - translateDelta.y
|
||||
const bottom = top - (scaledImageDimensions.height - SCREEN.height)
|
||||
|
||||
return [top, left, bottom, right]
|
||||
}
|
||||
|
||||
const getTranslateInBounds = (translate: Position, scale: number) => {
|
||||
const inBoundTranslate = { x: translate.x, y: translate.y }
|
||||
const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale)
|
||||
|
||||
if (translate.x > leftBound) {
|
||||
inBoundTranslate.x = leftBound
|
||||
} else if (translate.x < rightBound) {
|
||||
inBoundTranslate.x = rightBound
|
||||
}
|
||||
|
||||
if (translate.y > topBound) {
|
||||
inBoundTranslate.y = topBound
|
||||
} else if (translate.y < bottomBound) {
|
||||
inBoundTranslate.y = bottomBound
|
||||
}
|
||||
|
||||
return inBoundTranslate
|
||||
}
|
||||
|
||||
const fitsScreenByWidth = () =>
|
||||
imageDimensions.width * currentScale < SCREEN_WIDTH
|
||||
const fitsScreenByHeight = () =>
|
||||
imageDimensions.height * currentScale < SCREEN_HEIGHT
|
||||
|
||||
useEffect(() => {
|
||||
scaleValue.addListener(({ value }) => {
|
||||
if (typeof onZoom === 'function') {
|
||||
onZoom(value !== initialScale)
|
||||
}
|
||||
})
|
||||
|
||||
return () => scaleValue.removeAllListeners()
|
||||
})
|
||||
|
||||
const cancelLongPressHandle = () => {
|
||||
longPressHandlerRef && clearTimeout(longPressHandlerRef)
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
onGrant: (
|
||||
_: GestureResponderEvent,
|
||||
gestureState: PanResponderGestureState
|
||||
) => {
|
||||
numberInitialTouches = gestureState.numberActiveTouches
|
||||
|
||||
if (gestureState.numberActiveTouches > 1) return
|
||||
|
||||
// @ts-ignore
|
||||
longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
|
||||
},
|
||||
onStart: (
|
||||
event: GestureResponderEvent,
|
||||
gestureState: PanResponderGestureState
|
||||
) => {
|
||||
initialTouches = event.nativeEvent.touches
|
||||
numberInitialTouches = gestureState.numberActiveTouches
|
||||
|
||||
if (gestureState.numberActiveTouches > 1) return
|
||||
|
||||
const tapTS = Date.now()
|
||||
!timer &&
|
||||
// @ts-ignore
|
||||
(timer = setTimeout(() => onRequestClose(), DOUBLE_TAP_DELAY + 50))
|
||||
// Handle double tap event by calculating diff between first and second taps timestamps
|
||||
|
||||
isDoubleTapPerformed = Boolean(
|
||||
lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY
|
||||
)
|
||||
|
||||
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
|
||||
// @ts-ignore
|
||||
clearTimeout(timer)
|
||||
const isScaled = currentTranslate.x !== initialTranslate.x // currentScale !== initialScale;
|
||||
const { pageX: touchX, pageY: touchY } = event.nativeEvent.touches[0]
|
||||
const targetScale = SCALE_MAX
|
||||
const nextScale = isScaled ? initialScale : targetScale
|
||||
const nextTranslate = isScaled
|
||||
? initialTranslate
|
||||
: getTranslateInBounds(
|
||||
{
|
||||
x:
|
||||
initialTranslate.x +
|
||||
(SCREEN_WIDTH / 2 - touchX) * (targetScale / currentScale),
|
||||
y:
|
||||
initialTranslate.y +
|
||||
(SCREEN_HEIGHT / 2 - -touchY) * (targetScale / currentScale)
|
||||
},
|
||||
targetScale
|
||||
)
|
||||
|
||||
onZoom(!isScaled)
|
||||
|
||||
Animated.parallel(
|
||||
[
|
||||
Animated.timing(translateValue.x, {
|
||||
toValue: nextTranslate.x,
|
||||
duration: 300,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.timing(translateValue.y, {
|
||||
toValue: nextTranslate.y,
|
||||
duration: 300,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.timing(scaleValue, {
|
||||
toValue: nextScale,
|
||||
duration: 300,
|
||||
useNativeDriver: true
|
||||
})
|
||||
],
|
||||
{ stopTogether: false }
|
||||
).start(() => {
|
||||
currentScale = nextScale
|
||||
currentTranslate = nextTranslate
|
||||
})
|
||||
|
||||
lastTapTS = null
|
||||
timer = null
|
||||
} else {
|
||||
lastTapTS = Date.now()
|
||||
}
|
||||
},
|
||||
onMove: (
|
||||
event: GestureResponderEvent,
|
||||
gestureState: PanResponderGestureState
|
||||
) => {
|
||||
const { dx, dy } = gestureState
|
||||
|
||||
if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) {
|
||||
cancelLongPressHandle()
|
||||
timer && clearTimeout(timer)
|
||||
}
|
||||
|
||||
// Don't need to handle move because double tap in progress (was handled in onStart)
|
||||
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
|
||||
cancelLongPressHandle()
|
||||
timer && clearTimeout(timer)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
numberInitialTouches === 1 &&
|
||||
gestureState.numberActiveTouches === 2
|
||||
) {
|
||||
numberInitialTouches = 2
|
||||
initialTouches = event.nativeEvent.touches
|
||||
}
|
||||
|
||||
const isTapGesture =
|
||||
numberInitialTouches == 1 && gestureState.numberActiveTouches === 1
|
||||
const isPinchGesture =
|
||||
numberInitialTouches === 2 && gestureState.numberActiveTouches === 2
|
||||
|
||||
if (isPinchGesture) {
|
||||
cancelLongPressHandle()
|
||||
timer && clearTimeout(timer)
|
||||
|
||||
const initialDistance = getDistanceBetweenTouches(initialTouches)
|
||||
const currentDistance = getDistanceBetweenTouches(
|
||||
event.nativeEvent.touches
|
||||
)
|
||||
|
||||
let nextScale = (currentDistance / initialDistance) * currentScale
|
||||
|
||||
/**
|
||||
* In case image is scaling smaller than initial size ->
|
||||
* slow down this transition by applying OUT_BOUND_MULTIPLIER
|
||||
*/
|
||||
if (nextScale < initialScale) {
|
||||
nextScale =
|
||||
nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER
|
||||
}
|
||||
|
||||
/**
|
||||
* In case image is scaling down -> move it in direction of initial position
|
||||
*/
|
||||
if (currentScale > initialScale && currentScale > nextScale) {
|
||||
const k = (currentScale - initialScale) / (currentScale - nextScale)
|
||||
|
||||
const nextTranslateX =
|
||||
nextScale < initialScale
|
||||
? initialTranslate.x
|
||||
: currentTranslate.x -
|
||||
(currentTranslate.x - initialTranslate.x) / k
|
||||
|
||||
const nextTranslateY =
|
||||
nextScale < initialScale
|
||||
? initialTranslate.y
|
||||
: currentTranslate.y -
|
||||
(currentTranslate.y - initialTranslate.y) / k
|
||||
|
||||
translateValue.x.setValue(nextTranslateX)
|
||||
translateValue.y.setValue(nextTranslateY)
|
||||
|
||||
tmpTranslate = { x: nextTranslateX, y: nextTranslateY }
|
||||
}
|
||||
|
||||
scaleValue.setValue(nextScale)
|
||||
tmpScale = nextScale
|
||||
}
|
||||
|
||||
if (isTapGesture && currentScale > initialScale) {
|
||||
const { x, y } = currentTranslate
|
||||
const { dx, dy } = gestureState
|
||||
const [topBound, leftBound, bottomBound, rightBound] =
|
||||
getBounds(currentScale)
|
||||
|
||||
let nextTranslateX = x + dx
|
||||
let nextTranslateY = y + dy
|
||||
|
||||
if (nextTranslateX > leftBound) {
|
||||
nextTranslateX =
|
||||
nextTranslateX - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER
|
||||
}
|
||||
|
||||
if (nextTranslateX < rightBound) {
|
||||
nextTranslateX =
|
||||
nextTranslateX -
|
||||
(nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER
|
||||
}
|
||||
|
||||
if (nextTranslateY > topBound) {
|
||||
nextTranslateY =
|
||||
nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER
|
||||
}
|
||||
|
||||
if (nextTranslateY < bottomBound) {
|
||||
nextTranslateY =
|
||||
nextTranslateY -
|
||||
(nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER
|
||||
}
|
||||
|
||||
if (fitsScreenByWidth()) {
|
||||
nextTranslateX = x
|
||||
}
|
||||
|
||||
if (fitsScreenByHeight()) {
|
||||
nextTranslateY = y
|
||||
}
|
||||
|
||||
translateValue.x.setValue(nextTranslateX)
|
||||
translateValue.y.setValue(nextTranslateY)
|
||||
|
||||
tmpTranslate = { x: nextTranslateX, y: nextTranslateY }
|
||||
}
|
||||
},
|
||||
onRelease: () => {
|
||||
cancelLongPressHandle()
|
||||
|
||||
if (isDoubleTapPerformed) {
|
||||
isDoubleTapPerformed = false
|
||||
}
|
||||
|
||||
if (tmpScale > 0) {
|
||||
if (tmpScale < initialScale || tmpScale > SCALE_MAX) {
|
||||
tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX
|
||||
Animated.timing(scaleValue, {
|
||||
toValue: tmpScale,
|
||||
duration: 100,
|
||||
useNativeDriver: true
|
||||
}).start()
|
||||
}
|
||||
|
||||
currentScale = tmpScale
|
||||
tmpScale = 0
|
||||
}
|
||||
|
||||
if (tmpTranslate) {
|
||||
const { x, y } = tmpTranslate
|
||||
const [topBound, leftBound, bottomBound, rightBound] =
|
||||
getBounds(currentScale)
|
||||
|
||||
let nextTranslateX = x
|
||||
let nextTranslateY = y
|
||||
|
||||
if (!fitsScreenByWidth()) {
|
||||
if (nextTranslateX > leftBound) {
|
||||
nextTranslateX = leftBound
|
||||
} else if (nextTranslateX < rightBound) {
|
||||
nextTranslateX = rightBound
|
||||
}
|
||||
}
|
||||
|
||||
if (!fitsScreenByHeight()) {
|
||||
if (nextTranslateY > topBound) {
|
||||
nextTranslateY = topBound
|
||||
} else if (nextTranslateY < bottomBound) {
|
||||
nextTranslateY = bottomBound
|
||||
}
|
||||
}
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(translateValue.x, {
|
||||
toValue: nextTranslateX,
|
||||
duration: 100,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.timing(translateValue.y, {
|
||||
toValue: nextTranslateY,
|
||||
duration: 100,
|
||||
useNativeDriver: true
|
||||
})
|
||||
]).start()
|
||||
|
||||
currentTranslate = { x: nextTranslateX, y: nextTranslateY }
|
||||
tmpTranslate = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const panResponder = useMemo(() => createPanResponder(handlers), [handlers])
|
||||
|
||||
return [panResponder.panHandlers, scaleValue, translateValue]
|
||||
}
|
||||
|
||||
export default usePanResponder
|
@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
const useRequestClose = (onRequestClose: () => void) => {
|
||||
const [opacity, setOpacity] = useState(1)
|
||||
|
||||
return [
|
||||
opacity,
|
||||
() => {
|
||||
setOpacity(0)
|
||||
onRequestClose()
|
||||
}
|
||||
] as const
|
||||
}
|
||||
|
||||
export default useRequestClose
|
@ -1,147 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
Animated,
|
||||
GestureResponderEvent,
|
||||
PanResponder,
|
||||
PanResponderGestureState,
|
||||
PanResponderInstance,
|
||||
NativeTouchEvent
|
||||
} from 'react-native'
|
||||
import { Dimensions, Position } from './@types'
|
||||
|
||||
export const getImageTransform = (
|
||||
image: Dimensions | null,
|
||||
screen: Dimensions
|
||||
) => {
|
||||
if (!image?.width || !image?.height) {
|
||||
return [] as const
|
||||
}
|
||||
|
||||
const wScale = screen.width / image.width
|
||||
const hScale = screen.height / image.height
|
||||
const scale = Math.min(wScale, hScale)
|
||||
const { x, y } = getImageTranslate(image, screen)
|
||||
|
||||
return [{ x, y }, scale] as const
|
||||
}
|
||||
|
||||
export const getImageStyles = (
|
||||
image: Dimensions | null,
|
||||
translate: Animated.ValueXY,
|
||||
scale?: Animated.Value
|
||||
) => {
|
||||
if (!image?.width || !image?.height) {
|
||||
return { width: 0, height: 0 }
|
||||
}
|
||||
|
||||
const transform = translate.getTranslateTransform()
|
||||
|
||||
if (scale) {
|
||||
// @ts-ignore
|
||||
transform.push({ scale }, { perspective: new Animated.Value(1000) })
|
||||
}
|
||||
|
||||
return {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
transform
|
||||
}
|
||||
}
|
||||
|
||||
export const getImageTranslate = (
|
||||
image: Dimensions,
|
||||
screen: Dimensions
|
||||
): Position => {
|
||||
const getTranslateForAxis = (axis: 'x' | 'y'): number => {
|
||||
const imageSize = axis === 'x' ? image.width : image.height
|
||||
const screenSize = axis === 'x' ? screen.width : screen.height
|
||||
|
||||
return (screenSize - imageSize) / 2
|
||||
}
|
||||
|
||||
return {
|
||||
x: getTranslateForAxis('x'),
|
||||
y: getTranslateForAxis('y')
|
||||
}
|
||||
}
|
||||
|
||||
export const getImageDimensionsByTranslate = (
|
||||
translate: Position,
|
||||
screen: Dimensions
|
||||
): Dimensions => ({
|
||||
width: screen.width - translate.x * 2,
|
||||
height: screen.height - translate.y * 2
|
||||
})
|
||||
|
||||
export const getImageTranslateForScale = (
|
||||
currentTranslate: Position,
|
||||
targetScale: number,
|
||||
screen: Dimensions
|
||||
): Position => {
|
||||
const { width, height } = getImageDimensionsByTranslate(
|
||||
currentTranslate,
|
||||
screen
|
||||
)
|
||||
|
||||
const targetImageDimensions = {
|
||||
width: width * targetScale,
|
||||
height: height * targetScale
|
||||
}
|
||||
|
||||
return getImageTranslate(targetImageDimensions, screen)
|
||||
}
|
||||
|
||||
type HandlerType = (
|
||||
event: GestureResponderEvent,
|
||||
state: PanResponderGestureState
|
||||
) => void
|
||||
|
||||
type PanResponderProps = {
|
||||
onGrant: HandlerType
|
||||
onStart?: HandlerType
|
||||
onMove: HandlerType
|
||||
onRelease?: HandlerType
|
||||
onTerminate?: HandlerType
|
||||
}
|
||||
|
||||
export const createPanResponder = ({
|
||||
onGrant,
|
||||
onStart,
|
||||
onMove,
|
||||
onRelease,
|
||||
onTerminate
|
||||
}: PanResponderProps): PanResponderInstance =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onStartShouldSetPanResponderCapture: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponderCapture: () => true,
|
||||
onPanResponderGrant: onGrant,
|
||||
onPanResponderStart: onStart,
|
||||
onPanResponderMove: onMove,
|
||||
onPanResponderRelease: onRelease,
|
||||
onPanResponderTerminate: onTerminate,
|
||||
onPanResponderTerminationRequest: () => false,
|
||||
onShouldBlockNativeResponder: () => false
|
||||
})
|
||||
|
||||
export const getDistanceBetweenTouches = (
|
||||
touches: NativeTouchEvent[]
|
||||
): number => {
|
||||
const [a, b] = touches
|
||||
|
||||
if (a == null || b == null) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.sqrt(
|
||||
Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2)
|
||||
)
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import analytics from '@components/analytics'
|
||||
import GracefullyImage from '@components/GracefullyImage'
|
||||
import { HeaderCenter, HeaderLeft, HeaderRight } from '@components/Header'
|
||||
import { Message } from '@components/Message'
|
||||
import { useActionSheet } from '@expo/react-native-action-sheet'
|
||||
@ -6,15 +7,29 @@ import { RootStackScreenProps } from '@utils/navigation/navigators'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Platform, Share, StatusBar, View } from 'react-native'
|
||||
import {
|
||||
Dimensions,
|
||||
FlatList,
|
||||
PixelRatio,
|
||||
Platform,
|
||||
Share,
|
||||
StatusBar,
|
||||
View,
|
||||
ViewToken
|
||||
} from 'react-native'
|
||||
import FlashMessage from 'react-native-flash-message'
|
||||
import {
|
||||
SafeAreaProvider,
|
||||
useSafeAreaInsets
|
||||
} from 'react-native-safe-area-context'
|
||||
import ImageViewer from './ImageViewer/Root'
|
||||
Directions,
|
||||
FlingGestureHandler,
|
||||
LongPressGestureHandler,
|
||||
State
|
||||
} from 'react-native-gesture-handler'
|
||||
import { Zoom, createZoomListComponent } from 'react-native-reanimated-zoom'
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import saveImage from './ImageViewer/save'
|
||||
|
||||
const ZoomFlatList = createZoomListComponent(FlatList)
|
||||
|
||||
const ScreenImagesViewer = ({
|
||||
route: {
|
||||
params: { imageUrls, id }
|
||||
@ -26,6 +41,9 @@ const ScreenImagesViewer = ({
|
||||
return null
|
||||
}
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get('screen').width
|
||||
const SCREEN_HEIGHT = Dimensions.get('screen').height
|
||||
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const { mode, theme } = useTheme()
|
||||
@ -34,6 +52,7 @@ const ScreenImagesViewer = ({
|
||||
const initialIndex = imageUrls.findIndex(image => image.id === id)
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex)
|
||||
|
||||
const listRef = useRef<FlatList>(null)
|
||||
const messageRef = useRef<FlashMessage>(null)
|
||||
|
||||
const { showActionSheetWithOptions } = useActionSheet()
|
||||
@ -71,84 +90,153 @@ const ScreenImagesViewer = ({
|
||||
)
|
||||
}, [currentIndex])
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({
|
||||
item
|
||||
}: {
|
||||
item: RootStackScreenProps<'Screen-ImagesViewer'>['route']['params']['imageUrls'][0]
|
||||
}) => {
|
||||
const screenRatio = SCREEN_WIDTH / SCREEN_HEIGHT
|
||||
const imageRatio = item.width && item.height ? item.width / item.height : 1
|
||||
const imageWidth = item.width || 100
|
||||
const imageHeight = item.height || 100
|
||||
|
||||
const maxWidthScale = item.width ? (item.width / SCREEN_WIDTH / PixelRatio.get()) * 4 : 0
|
||||
const maxHeightScale = item.height ? (item.height / SCREEN_WIDTH / PixelRatio.get()) * 4 : 0
|
||||
const max = Math.max.apply(Math, [maxWidthScale, maxHeightScale, 4])
|
||||
|
||||
return (
|
||||
<Zoom
|
||||
maximumZoomScale={max > 8 ? 8 : max}
|
||||
children={
|
||||
<View
|
||||
style={{
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<GracefullyImage
|
||||
uri={{ preview: item.preview_url, remote: item.remote_url, original: item.url }}
|
||||
blurhash={item.blurhash}
|
||||
dimension={{
|
||||
width:
|
||||
screenRatio > imageRatio
|
||||
? (SCREEN_HEIGHT / imageHeight) * imageWidth
|
||||
: SCREEN_WIDTH,
|
||||
height:
|
||||
screenRatio > imageRatio
|
||||
? SCREEN_HEIGHT
|
||||
: (SCREEN_WIDTH / imageWidth) * imageHeight
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const onViewableItemsChanged = useCallback(
|
||||
({ viewableItems }: { viewableItems: ViewToken[] }) => {
|
||||
setCurrentIndex(viewableItems[0].index || 0)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<SafeAreaProvider style={{ backgroundColor: 'black' }}>
|
||||
<StatusBar hidden />
|
||||
<ImageViewer
|
||||
images={imageUrls}
|
||||
imageIndex={initialIndex}
|
||||
onImageIndexChange={index => setCurrentIndex(index)}
|
||||
onRequestClose={() => navigation.goBack()}
|
||||
onLongPress={() => {
|
||||
analytics('imageviewer_more_press')
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options: [
|
||||
t('content.options.save'),
|
||||
t('content.options.share'),
|
||||
t('content.options.cancel')
|
||||
],
|
||||
cancelButtonIndex: 2,
|
||||
userInterfaceStyle: mode
|
||||
},
|
||||
async buttonIndex => {
|
||||
switch (buttonIndex) {
|
||||
case 0:
|
||||
analytics('imageviewer_more_save_press')
|
||||
saveImage({
|
||||
messageRef,
|
||||
theme,
|
||||
image: imageUrls[currentIndex]
|
||||
})
|
||||
break
|
||||
case 1:
|
||||
analytics('imageviewer_more_share_press')
|
||||
switch (Platform.OS) {
|
||||
case 'ios':
|
||||
await Share.share({ url: imageUrls[currentIndex].url })
|
||||
break
|
||||
case 'android':
|
||||
await Share.share({
|
||||
message: imageUrls[currentIndex].url
|
||||
})
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
)
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: insets.top,
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
zIndex: 999
|
||||
}}
|
||||
HeaderComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: insets.top
|
||||
>
|
||||
<HeaderLeft content='X' native={false} background onPress={() => navigation.goBack()} />
|
||||
<HeaderCenter inverted content={`${currentIndex + 1} / ${imageUrls.length}`} />
|
||||
<HeaderRight
|
||||
accessibilityLabel={t('content.actions.accessibilityLabel')}
|
||||
accessibilityHint={t('content.actions.accessibilityHint')}
|
||||
content='MoreHorizontal'
|
||||
native={false}
|
||||
background
|
||||
onPress={onPress}
|
||||
/>
|
||||
</View>
|
||||
<FlingGestureHandler
|
||||
direction={Directions.DOWN}
|
||||
numberOfPointers={1}
|
||||
onEnded={() => navigation.goBack()}
|
||||
>
|
||||
<LongPressGestureHandler
|
||||
onEnded={() => {
|
||||
analytics('imageviewer_more_press')
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options: [
|
||||
t('content.options.save'),
|
||||
t('content.options.share'),
|
||||
t('content.options.cancel')
|
||||
],
|
||||
cancelButtonIndex: 2,
|
||||
userInterfaceStyle: mode
|
||||
},
|
||||
async buttonIndex => {
|
||||
switch (buttonIndex) {
|
||||
case 0:
|
||||
analytics('imageviewer_more_save_press')
|
||||
saveImage({
|
||||
messageRef,
|
||||
theme,
|
||||
image: imageUrls[currentIndex]
|
||||
})
|
||||
break
|
||||
case 1:
|
||||
analytics('imageviewer_more_share_press')
|
||||
switch (Platform.OS) {
|
||||
case 'ios':
|
||||
await Share.share({ url: imageUrls[currentIndex].url })
|
||||
break
|
||||
case 'android':
|
||||
await Share.share({
|
||||
message: imageUrls[currentIndex].url
|
||||
})
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
)
|
||||
}}
|
||||
>
|
||||
<ZoomFlatList
|
||||
data={imageUrls}
|
||||
pagingEnabled
|
||||
horizontal
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderItem}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
viewabilityConfig={{
|
||||
itemVisiblePercentThreshold: 50
|
||||
}}
|
||||
>
|
||||
<HeaderLeft
|
||||
content='X'
|
||||
native={false}
|
||||
background
|
||||
onPress={() => navigation.goBack()}
|
||||
/>
|
||||
<HeaderCenter
|
||||
inverted
|
||||
content={`${currentIndex + 1} / ${imageUrls.length}`}
|
||||
/>
|
||||
<HeaderRight
|
||||
accessibilityLabel={t('content.actions.accessibilityLabel')}
|
||||
accessibilityHint={t('content.actions.accessibilityHint')}
|
||||
content='MoreHorizontal'
|
||||
native={false}
|
||||
background
|
||||
onPress={onPress}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
initialScrollIndex={initialIndex}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: SCREEN_WIDTH,
|
||||
offset: SCREEN_WIDTH * index,
|
||||
index
|
||||
})}
|
||||
/>
|
||||
</LongPressGestureHandler>
|
||||
</FlingGestureHandler>
|
||||
<Message ref={messageRef} />
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
|
@ -7081,6 +7081,11 @@ react-native-pager-view@^5.4.25:
|
||||
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-5.4.25.tgz#cd639d5387a7f3d5581b55a33c5faa1cbc200f97"
|
||||
integrity sha512-3drrYwaLat2fYszymZe3nHMPASJ4aJMaxiejfA1V5SK3OygYmdtmV2u5prX7TnjueJzGSyyaCYEr2JlrRt4YPg==
|
||||
|
||||
react-native-reanimated-zoom@^0.3.0:
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/react-native-reanimated-zoom/-/react-native-reanimated-zoom-0.3.0.tgz#181825c1854853a4db33a86e521057e2e757d290"
|
||||
integrity sha512-kvYVbLayX8Tj52oDmKE78gnEmZD5KsCHxkTSrMfahq9KyqU6aHWistfocFtzBBT+I0puzcHpivzy3dxYL1SL5Q==
|
||||
|
||||
react-native-reanimated@^2.9.1:
|
||||
version "2.9.1"
|
||||
resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-2.9.1.tgz#d9a932e312c13c05b4f919e43ebbf76d996e0bc1"
|
||||
|
Loading…
x
Reference in New Issue
Block a user