diff --git a/package.json b/package.json index e511625b..05d1b444 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "native": "220206", "major": 3, "minor": 4, - "patch": 4, + "patch": 5, "expo": "44.0.0" }, "description": "tooot app for Mastodon", diff --git a/src/Screens.tsx b/src/Screens.tsx index 632452a9..718c0e29 100644 --- a/src/Screens.tsx +++ b/src/Screens.tsx @@ -14,6 +14,7 @@ import pushUseConnect from '@utils/push/useConnect' import pushUseReceive from '@utils/push/useReceive' import pushUseRespond from '@utils/push/useRespond' import { updatePreviousTab } from '@utils/slices/contextsSlice' +import { checkEmojis } from '@utils/slices/instances/checkEmojis' import { updateAccountPreferences } from '@utils/slices/instances/updateAccountPreferences' import { updateConfiguration } from '@utils/slices/instances/updateConfiguration' import { updateFilters } from '@utils/slices/instances/updateFilters' @@ -92,6 +93,7 @@ const Screens: React.FC = ({ localCorrupt }) => { dispatch(updateConfiguration()) dispatch(updateFilters()) dispatch(updateAccountPreferences()) + dispatch(checkEmojis()) } }, [instanceActive]) diff --git a/src/components/Emojis.tsx b/src/components/Emojis.tsx index 7854eeca..0eafa302 100644 --- a/src/components/Emojis.tsx +++ b/src/components/Emojis.tsx @@ -2,6 +2,7 @@ import EmojisButton from '@components/Emojis/Button' import EmojisList from '@components/Emojis/List' import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { useEmojisQuery } from '@utils/queryHooks/emojis' +import { getInstanceFrequentEmojis } from '@utils/slices/instancesSlice' import { chunk, forEach, groupBy, sortBy } from 'lodash' import React, { Dispatch, @@ -11,11 +12,16 @@ import React, { useEffect, useReducer } from 'react' +import { useTranslation } from 'react-i18next' import FastImage from 'react-native-fast-image' +import { useSelector } from 'react-redux' import EmojisContext, { emojisReducer } from './Emojis/helpers/EmojisContext' const prefetchEmojis = ( - sortedEmojis: { title: string; data: Mastodon.Emoji[][] }[], + sortedEmojis: { + title: string + data: Pick[][] + }[], reduceMotionEnabled: boolean ) => { const prefetches: { uri: string }[] = [] @@ -101,14 +107,28 @@ const ComponentEmojis: React.FC = ({ [value, selectionRange.current?.start, selectionRange.current?.end] ) + const { t } = useTranslation() const { data } = useEmojisQuery({ options: { enabled } }) + const frequentEmojis = useSelector(getInstanceFrequentEmojis, () => true) useEffect(() => { if (data && data.length) { - let sortedEmojis: { title: string; data: Mastodon.Emoji[][] }[] = [] + let sortedEmojis: { + title: string + data: Pick[][] + }[] = [] forEach( groupBy(sortBy(data, ['category', 'shortcode']), 'category'), (value, key) => sortedEmojis.push({ title: key, data: chunk(value, 5) }) ) + if (frequentEmojis.length) { + sortedEmojis.unshift({ + title: t('componentEmojis:frequentUsed'), + data: chunk( + frequentEmojis.map(e => e.emoji), + 5 + ) + }) + } emojisDispatch({ type: 'load', payload: sortedEmojis diff --git a/src/components/Emojis/List.tsx b/src/components/Emojis/List.tsx index c143e3c8..1f60f338 100644 --- a/src/components/Emojis/List.tsx +++ b/src/components/Emojis/List.tsx @@ -1,4 +1,5 @@ import { useAccessibility } from '@utils/accessibility/AccessibilityManager' +import { countInstanceEmoji } from '@utils/slices/instancesSlice' import { StyleConstants } from '@utils/styles/constants' import layoutAnimation from '@utils/styles/layoutAnimation' import { useTheme } from '@utils/styles/ThemeManager' @@ -14,11 +15,13 @@ import { View } from 'react-native' import FastImage from 'react-native-fast-image' +import { useDispatch } from 'react-redux' import validUrl from 'valid-url' import EmojisContext from './helpers/EmojisContext' const EmojisList = React.memo( () => { + const dispatch = useDispatch() const { reduceMotionEnabled } = useAccessibility() const { t } = useTranslation() @@ -42,12 +45,13 @@ const EmojisList = React.memo( return ( + onPress={() => { emojisDispatch({ type: 'shortcode', payload: `:${emoji.shortcode}:` }) - } + dispatch(countInstanceEmoji(emoji)) + }} > [][] + }[] shortcode: Mastodon.Emoji['shortcode'] | null } diff --git a/src/i18n/en/_all.ts b/src/i18n/en/_all.ts index d4c1b7c5..9032bb7b 100644 --- a/src/i18n/en/_all.ts +++ b/src/i18n/en/_all.ts @@ -8,6 +8,7 @@ export default { screenImageViewer: require('./screens/imageViewer'), screenTabs: require('./screens/tabs'), + componentEmojis: require('./components/emojis'), componentInstance: require('./components/instance'), componentMediaSelector: require('./components/mediaSelector'), componentParse: require('./components/parse'), diff --git a/src/i18n/en/components/emojis.json b/src/i18n/en/components/emojis.json new file mode 100644 index 00000000..579c534b --- /dev/null +++ b/src/i18n/en/components/emojis.json @@ -0,0 +1,3 @@ +{ + "frequentUsed": "Frequent used" +} \ No newline at end of file diff --git a/src/i18n/ko/_all.ts b/src/i18n/ko/_all.ts index d4c1b7c5..9032bb7b 100644 --- a/src/i18n/ko/_all.ts +++ b/src/i18n/ko/_all.ts @@ -8,6 +8,7 @@ export default { screenImageViewer: require('./screens/imageViewer'), screenTabs: require('./screens/tabs'), + componentEmojis: require('./components/emojis'), componentInstance: require('./components/instance'), componentMediaSelector: require('./components/mediaSelector'), componentParse: require('./components/parse'), diff --git a/src/i18n/ko/components/emojis.json b/src/i18n/ko/components/emojis.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/ko/components/emojis.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/vi/_all.ts b/src/i18n/vi/_all.ts index d4c1b7c5..9032bb7b 100644 --- a/src/i18n/vi/_all.ts +++ b/src/i18n/vi/_all.ts @@ -8,6 +8,7 @@ export default { screenImageViewer: require('./screens/imageViewer'), screenTabs: require('./screens/tabs'), + componentEmojis: require('./components/emojis'), componentInstance: require('./components/instance'), componentMediaSelector: require('./components/mediaSelector'), componentParse: require('./components/parse'), diff --git a/src/i18n/vi/components/emojis.json b/src/i18n/vi/components/emojis.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/vi/components/emojis.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/zh-Hans/_all.ts b/src/i18n/zh-Hans/_all.ts index d4c1b7c5..9032bb7b 100644 --- a/src/i18n/zh-Hans/_all.ts +++ b/src/i18n/zh-Hans/_all.ts @@ -8,6 +8,7 @@ export default { screenImageViewer: require('./screens/imageViewer'), screenTabs: require('./screens/tabs'), + componentEmojis: require('./components/emojis'), componentInstance: require('./components/instance'), componentMediaSelector: require('./components/mediaSelector'), componentParse: require('./components/parse'), diff --git a/src/i18n/zh-Hans/components/emojis.json b/src/i18n/zh-Hans/components/emojis.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/zh-Hans/components/emojis.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/screens/Compose/Root.tsx b/src/screens/Compose/Root.tsx index 124d447b..f2270d8a 100644 --- a/src/screens/Compose/Root.tsx +++ b/src/screens/Compose/Root.tsx @@ -30,7 +30,11 @@ import FastImage from 'react-native-fast-image' import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { ComposeState } from './utils/types' import { useSelector } from 'react-redux' -import { getInstanceConfigurationStatusCharsURL } from '@utils/slices/instancesSlice' +import { + getInstanceConfigurationStatusCharsURL, + getInstanceFrequentEmojis +} from '@utils/slices/instancesSlice' +import { useTranslation } from 'react-i18next' const prefetchEmojis = ( sortedEmojis: NonNullable, @@ -99,15 +103,29 @@ const ComposeRoot = React.memo( } }, [composeState.tag]) + const { t } = useTranslation() const { data: emojisData } = useEmojisQuery({}) + const frequentEmojis = useSelector(getInstanceFrequentEmojis, () => true) useEffect(() => { if (emojisData && emojisData.length) { - let sortedEmojis: { title: string; data: Mastodon.Emoji[][] }[] = [] + const sortedEmojis: { + title: string + data: Pick[][] + }[] = [] forEach( groupBy(sortBy(emojisData, ['category', 'shortcode']), 'category'), (value, key) => sortedEmojis.push({ title: key, data: chunk(value, 5) }) ) + if (frequentEmojis.length) { + sortedEmojis.unshift({ + title: t('componentEmojis:frequentUsed'), + data: chunk( + frequentEmojis.map(e => e.emoji), + 5 + ) + }) + } composeDispatch({ type: 'emoji', payload: { ...composeState.emoji, emojis: sortedEmojis } diff --git a/src/screens/Compose/Root/Footer/Emojis.tsx b/src/screens/Compose/Root/Footer/Emojis.tsx index 1491ab88..71841734 100644 --- a/src/screens/Compose/Root/Footer/Emojis.tsx +++ b/src/screens/Compose/Root/Footer/Emojis.tsx @@ -1,5 +1,6 @@ import haptics from '@components/haptics' import { useAccessibility } from '@utils/accessibility/AccessibilityManager' +import { countInstanceEmoji } from '@utils/slices/instancesSlice' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import React, { RefObject, useCallback, useContext, useEffect } from 'react' @@ -14,6 +15,7 @@ import { View } from 'react-native' import FastImage from 'react-native-fast-image' +import { useDispatch } from 'react-redux' import validUrl from 'valid-url' import updateText from '../../updateText' import ComposeContext from '../../utils/createContext' @@ -27,6 +29,7 @@ const ComposeEmojis: React.FC = ({ accessibleRefEmojis }) => { const { reduceMotionEnabled } = useAccessibility() const { colors } = useTheme() const { t } = useTranslation() + const dispatch = useDispatch() useEffect(() => { const tagEmojis = findNodeHandle(accessibleRefEmojis.current) @@ -53,13 +56,14 @@ const ComposeEmojis: React.FC = ({ accessibleRefEmojis }) => { { + haptics('Light') updateText({ composeState, composeDispatch, newText: `:${emoji.shortcode}:`, type: 'emoji' }) - haptics('Light') + dispatch(countInstanceEmoji(emoji)) }} > [][] + }[] + | undefined } poll: { active: boolean diff --git a/src/store.ts b/src/store.ts index df8ccc21..4df61af2 100644 --- a/src/store.ts +++ b/src/store.ts @@ -27,7 +27,7 @@ const instancesPersistConfig = { key: 'instances', prefix, storage: secureStorage, - version: 7, + version: 8, // @ts-ignore migrate: createMigrate(instancesMigration) } diff --git a/src/utils/migrations/instances/migration.ts b/src/utils/migrations/instances/migration.ts index 70d175d9..f45abb0a 100644 --- a/src/utils/migrations/instances/migration.ts +++ b/src/utils/migrations/instances/migration.ts @@ -3,6 +3,7 @@ import { InstanceV4 } from './v4' import { InstanceV5 } from './v5' import { InstanceV6 } from './v6' import { InstanceV7 } from './v7' +import { InstanceV8 } from './v8' const instancesMigration = { 4: (state: InstanceV3): InstanceV4 => { @@ -76,6 +77,16 @@ const instancesMigration = { } }) } + }, + 8: (state: InstanceV7): InstanceV8 => { + return { + instances: state.instances.map(instance => { + return { + ...instance, + frequentEmojis: [] + } + }) + } } } diff --git a/src/utils/migrations/instances/v8.ts b/src/utils/migrations/instances/v8.ts new file mode 100644 index 00000000..675c6628 --- /dev/null +++ b/src/utils/migrations/instances/v8.ts @@ -0,0 +1,83 @@ +import { ComposeStateDraft } from '@screens/Compose/utils/types' +import { QueryKeyTimeline } from '@utils/queryHooks/timeline' + +type Instance = { + active: boolean + appData: { + clientId: string + clientSecret: string + } + url: string + token: string + uri: Mastodon.Instance['uri'] + urls: Mastodon.Instance['urls'] + account: { + id: Mastodon.Account['id'] + acct: Mastodon.Account['acct'] + avatarStatic: Mastodon.Account['avatar_static'] + preferences: Mastodon.Preferences + } + max_toot_chars?: number // To be deprecated in v4 + configuration?: Mastodon.Instance['configuration'] + filters: Mastodon.Filter[] + notifications_filter: { + follow: boolean + favourite: boolean + reblog: boolean + mention: boolean + poll: boolean + follow_request: boolean + } + push: { + global: { loading: boolean; value: boolean } + decode: { loading: boolean; value: boolean } + alerts: { + follow: { + loading: boolean + value: Mastodon.PushSubscription['alerts']['follow'] + } + favourite: { + loading: boolean + value: Mastodon.PushSubscription['alerts']['favourite'] + } + reblog: { + loading: boolean + value: Mastodon.PushSubscription['alerts']['reblog'] + } + mention: { + loading: boolean + value: Mastodon.PushSubscription['alerts']['mention'] + } + poll: { + loading: boolean + value: Mastodon.PushSubscription['alerts']['poll'] + } + } + keys: { + auth?: string + public?: string // legacy + private?: string // legacy + } + } + timelinesLookback?: { + [key: string]: { + queryKey: QueryKeyTimeline + ids: Mastodon.Status['id'][] + } + } + mePage: { + lists: { shown: boolean } + announcements: { shown: boolean; unread: number } + } + drafts: ComposeStateDraft[] + frequentEmojis: { + emoji: Pick + score: number + count: number + lastUsed: Date + }[] +} + +export type InstanceV8 = { + instances: Instance[] +} diff --git a/src/utils/slices/instances/checkEmojis.ts b/src/utils/slices/instances/checkEmojis.ts new file mode 100644 index 00000000..d1e0c445 --- /dev/null +++ b/src/utils/slices/instances/checkEmojis.ts @@ -0,0 +1,15 @@ +import apiInstance from '@api/instance' +import queryClient from '@helpers/queryClient' +import { createAsyncThunk } from '@reduxjs/toolkit' + +export const checkEmojis = createAsyncThunk( + 'instances/checkEmojis', + async (): Promise => { + const res = await apiInstance({ + method: 'get', + url: 'custom_emojis' + }).then(res => res.body) + queryClient.setQueryData(['Emojis'], res) + return res + } +) diff --git a/src/utils/slices/instancesSlice.ts b/src/utils/slices/instancesSlice.ts index 3bc3b4d9..b6b553cb 100644 --- a/src/utils/slices/instancesSlice.ts +++ b/src/utils/slices/instancesSlice.ts @@ -4,6 +4,7 @@ import { RootState } from '@root/store' import { ComposeStateDraft } from '@screens/Compose/utils/types' import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import addInstance from './instances/add' +import { checkEmojis } from './instances/checkEmojis' import removeInstance from './instances/remove' import { updateAccountPreferences } from './instances/updateAccountPreferences' import { updateConfiguration } from './instances/updateConfiguration' @@ -81,6 +82,12 @@ export type Instance = { announcements: { shown: boolean; unread: number } } drafts: ComposeStateDraft[] + frequentEmojis: { + emoji: Pick + score: number + count: number + lastUsed: number + }[] } export type InstancesState = { @@ -184,6 +191,56 @@ const instancesSlice = createSlice({ ...instances[activeIndex].mePage, ...action.payload } + }, + countInstanceEmoji: ( + { instances }, + action: PayloadAction + ) => { + const HALF_LIFE = 60 * 60 * 24 * 7 // 1 week + const calculateScore = (emoji: Instance['frequentEmojis'][0]): number => { + var seconds = (new Date().getTime() - emoji.lastUsed) / 1000 + var score = emoji.count + 1 + var order = Math.log(Math.max(score, 1)) / Math.LN10 + var sign = score > 0 ? 1 : score === 0 ? 0 : -1 + return (sign * order + seconds / HALF_LIFE) * 10 + } + const activeIndex = findInstanceActive(instances) + const foundEmojiIndex = instances[activeIndex].frequentEmojis?.findIndex( + e => + e.emoji.shortcode === action.payload.shortcode && + e.emoji.url === action.payload.url + ) + let newEmojisSort: Instance['frequentEmojis'] + if (foundEmojiIndex > -1) { + newEmojisSort = instances[activeIndex].frequentEmojis + .map((e, i) => + i === foundEmojiIndex + ? { + ...e, + score: calculateScore(e), + count: e.count + 1, + lastUsed: new Date().getTime() + } + : e + ) + .sort((a, b) => b.score - a.score) + } else { + newEmojisSort = instances[activeIndex].frequentEmojis || [] + const temp = { + emoji: action.payload, + score: 0, + count: 0, + lastUsed: new Date().getTime() + } + newEmojisSort.push({ + ...temp, + score: calculateScore(temp), + count: temp.count + 1 + }) + } + instances[activeIndex].frequentEmojis = newEmojisSort + .sort((a, b) => b.score - a.score) + .slice(0, 20) } }, extraReducers: builder => { @@ -321,6 +378,22 @@ const instancesSlice = createSlice({ action.meta.arg.changed ].loading = true }) + + // Check if frequently used emojis still exist + .addCase(checkEmojis.fulfilled, (state, action) => { + const activeIndex = findInstanceActive(state.instances) + state.instances[activeIndex].frequentEmojis = state.instances[ + activeIndex + ].frequentEmojis?.filter(emoji => { + return action.payload.find( + e => + e.shortcode === emoji.emoji.shortcode && e.url === emoji.emoji.url + ) + }) + }) + .addCase(checkEmojis.rejected, (_, action) => { + console.error(action.error) + }) } }) @@ -394,6 +467,10 @@ export const getInstanceMePage = ({ instances: { instances } }: RootState) => export const getInstanceDrafts = ({ instances: { instances } }: RootState) => instances[findInstanceActive(instances)]?.drafts +export const getInstanceFrequentEmojis = ({ + instances: { instances } +}: RootState) => instances[findInstanceActive(instances)]?.frequentEmojis + export const { updateInstanceActive, updateInstanceAccount, @@ -403,7 +480,8 @@ export const { clearPushLoading, disableAllPushes, updateInstanceTimelineLookback, - updateInstanceMePage + updateInstanceMePage, + countInstanceEmoji } = instancesSlice.actions export default instancesSlice.reducer