Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master
This commit is contained in:
		
						commit
						ae55717f50
					
				
					 128 changed files with 1274 additions and 1030 deletions
				
			
		
							
								
								
									
										4
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								Gemfile
									
									
									
									
									
								
							|  | @ -13,11 +13,11 @@ gem 'pg', '~> 0.20' | ||||||
| gem 'pghero', '~> 1.7' | gem 'pghero', '~> 1.7' | ||||||
| gem 'dotenv-rails', '~> 2.2' | gem 'dotenv-rails', '~> 2.2' | ||||||
| 
 | 
 | ||||||
| gem 'aws-sdk', '~> 2.10', require: false | gem 'aws-sdk-s3', '~> 1.8', require: false | ||||||
| gem 'fog-core', '~> 1.45' | gem 'fog-core', '~> 1.45' | ||||||
| gem 'fog-local', '~> 0.4', require: false | gem 'fog-local', '~> 0.4', require: false | ||||||
| gem 'fog-openstack', '~> 0.1', require: false | gem 'fog-openstack', '~> 0.1', require: false | ||||||
| gem 'paperclip', '~> 5.1' | gem 'paperclip', '~> 6.0' | ||||||
| gem 'paperclip-av-transcoder', '~> 0.6' | gem 'paperclip-av-transcoder', '~> 0.6' | ||||||
| gem 'posix-spawn' | gem 'posix-spawn' | ||||||
| gem 'streamio-ffmpeg', '~> 3.0' | gem 'streamio-ffmpeg', '~> 3.0' | ||||||
|  |  | ||||||
							
								
								
									
										29
									
								
								Gemfile.lock
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								Gemfile.lock
									
									
									
									
									
								
							|  | @ -57,13 +57,18 @@ GEM | ||||||
|       encryptor (~> 3.0.0) |       encryptor (~> 3.0.0) | ||||||
|     av (0.9.0) |     av (0.9.0) | ||||||
|       cocaine (~> 0.5.3) |       cocaine (~> 0.5.3) | ||||||
|     aws-sdk (2.10.100) |     aws-partitions (1.70.0) | ||||||
|       aws-sdk-resources (= 2.10.100) |     aws-sdk-core (3.17.0) | ||||||
|     aws-sdk-core (2.10.100) |       aws-partitions (~> 1.0) | ||||||
|       aws-sigv4 (~> 1.0) |       aws-sigv4 (~> 1.0) | ||||||
|       jmespath (~> 1.0) |       jmespath (~> 1.0) | ||||||
|     aws-sdk-resources (2.10.100) |     aws-sdk-kms (1.5.0) | ||||||
|       aws-sdk-core (= 2.10.100) |       aws-sdk-core (~> 3) | ||||||
|  |       aws-sigv4 (~> 1.0) | ||||||
|  |     aws-sdk-s3 (1.8.2) | ||||||
|  |       aws-sdk-core (~> 3) | ||||||
|  |       aws-sdk-kms (~> 1) | ||||||
|  |       aws-sigv4 (~> 1.0) | ||||||
|     aws-sigv4 (1.0.2) |     aws-sigv4 (1.0.2) | ||||||
|     bcrypt (3.1.11) |     bcrypt (3.1.11) | ||||||
|     better_errors (2.4.0) |     better_errors (2.4.0) | ||||||
|  | @ -238,7 +243,7 @@ GEM | ||||||
|     httplog (0.99.7) |     httplog (0.99.7) | ||||||
|       colorize |       colorize | ||||||
|       rack |       rack | ||||||
|     i18n (0.9.3) |     i18n (0.9.5) | ||||||
|       concurrent-ruby (~> 1.0) |       concurrent-ruby (~> 1.0) | ||||||
|     i18n-tasks (0.9.19) |     i18n-tasks (0.9.19) | ||||||
|       activesupport (>= 4.0.2) |       activesupport (>= 4.0.2) | ||||||
|  | @ -344,12 +349,12 @@ GEM | ||||||
|       http (~> 3.0) |       http (~> 3.0) | ||||||
|       nokogiri (~> 1.8) |       nokogiri (~> 1.8) | ||||||
|     ox (2.8.2) |     ox (2.8.2) | ||||||
|     paperclip (5.2.1) |     paperclip (6.0.0) | ||||||
|       activemodel (>= 4.2.0) |       activemodel (>= 4.2.0) | ||||||
|       activesupport (>= 4.2.0) |       activesupport (>= 4.2.0) | ||||||
|       cocaine (~> 0.5.5) |  | ||||||
|       mime-types |       mime-types | ||||||
|       mimemagic (~> 0.3.0) |       mimemagic (~> 0.3.0) | ||||||
|  |       terrapin (~> 0.6.0) | ||||||
|     paperclip-av-transcoder (0.6.4) |     paperclip-av-transcoder (0.6.4) | ||||||
|       av (~> 0.9.0) |       av (~> 0.9.0) | ||||||
|       paperclip (>= 2.5.2) |       paperclip (>= 2.5.2) | ||||||
|  | @ -555,6 +560,8 @@ GEM | ||||||
|     temple (0.8.0) |     temple (0.8.0) | ||||||
|     terminal-table (1.8.0) |     terminal-table (1.8.0) | ||||||
|       unicode-display_width (~> 1.1, >= 1.1.1) |       unicode-display_width (~> 1.1, >= 1.1.1) | ||||||
|  |     terrapin (0.6.0) | ||||||
|  |       climate_control (>= 0.0.3, < 1.0) | ||||||
|     thor (0.20.0) |     thor (0.20.0) | ||||||
|     thread (0.2.2) |     thread (0.2.2) | ||||||
|     thread_safe (0.3.6) |     thread_safe (0.3.6) | ||||||
|  | @ -578,7 +585,7 @@ GEM | ||||||
|     tty-screen (0.6.4) |     tty-screen (0.6.4) | ||||||
|     twitter-text (1.14.7) |     twitter-text (1.14.7) | ||||||
|       unf (~> 0.1.0) |       unf (~> 0.1.0) | ||||||
|     tzinfo (1.2.4) |     tzinfo (1.2.5) | ||||||
|       thread_safe (~> 0.1) |       thread_safe (~> 0.1) | ||||||
|     tzinfo-data (1.2017.3) |     tzinfo-data (1.2017.3) | ||||||
|       tzinfo (>= 1.0.0) |       tzinfo (>= 1.0.0) | ||||||
|  | @ -615,7 +622,7 @@ DEPENDENCIES | ||||||
|   active_record_query_trace (~> 1.5) |   active_record_query_trace (~> 1.5) | ||||||
|   addressable (~> 2.5) |   addressable (~> 2.5) | ||||||
|   annotate (~> 2.7) |   annotate (~> 2.7) | ||||||
|   aws-sdk (~> 2.10) |   aws-sdk-s3 (~> 1.8) | ||||||
|   better_errors (~> 2.4) |   better_errors (~> 2.4) | ||||||
|   binding_of_caller (~> 0.7) |   binding_of_caller (~> 0.7) | ||||||
|   bootsnap |   bootsnap | ||||||
|  | @ -675,7 +682,7 @@ DEPENDENCIES | ||||||
|   omniauth-saml (~> 1.10) |   omniauth-saml (~> 1.10) | ||||||
|   ostatus2 (~> 2.0) |   ostatus2 (~> 2.0) | ||||||
|   ox (~> 2.8) |   ox (~> 2.8) | ||||||
|   paperclip (~> 5.1) |   paperclip (~> 6.0) | ||||||
|   paperclip-av-transcoder (~> 0.6) |   paperclip-av-transcoder (~> 0.6) | ||||||
|   parallel_tests (~> 2.17) |   parallel_tests (~> 2.17) | ||||||
|   pg (~> 0.20) |   pg (~> 0.20) | ||||||
|  |  | ||||||
|  | @ -60,9 +60,9 @@ module JsonLdHelper | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def fetch_resource_without_id_validation(uri) |   def fetch_resource_without_id_validation(uri) | ||||||
|     response = build_request(uri).perform |     build_request(uri).perform do |response| | ||||||
|     return if response.code != 200 |       response.code == 200 ? body_to_json(response.to_s) : nil | ||||||
|     body_to_json(response.to_s) |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def body_to_json(body) |   def body_to_json(body) | ||||||
|  |  | ||||||
|  | @ -1,4 +1,6 @@ | ||||||
| import api, { getLinks } from '../api'; | import api, { getLinks } from '../api'; | ||||||
|  | import asyncDB from '../db/async'; | ||||||
|  | import { importAccount, importFetchedAccount, importFetchedAccounts } from './importer'; | ||||||
| 
 | 
 | ||||||
| export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; | export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; | ||||||
| export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; | export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; | ||||||
|  | @ -64,6 +66,24 @@ export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; | ||||||
| export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; | export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; | ||||||
| export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL'; | export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL'; | ||||||
| 
 | 
 | ||||||
|  | function getFromDB(dispatch, getState, index, id) { | ||||||
|  |   return new Promise((resolve, reject) => { | ||||||
|  |     const request = index.get(id); | ||||||
|  | 
 | ||||||
|  |     request.onerror = reject; | ||||||
|  | 
 | ||||||
|  |     request.onsuccess = () => { | ||||||
|  |       if (!request.result) { | ||||||
|  |         reject(); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       dispatch(importAccount(request.result)); | ||||||
|  |       resolve(request.result.moved && getFromDB(dispatch, getState, index, request.result.moved)); | ||||||
|  |     }; | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export function fetchAccount(id) { | export function fetchAccount(id) { | ||||||
|   return (dispatch, getState) => { |   return (dispatch, getState) => { | ||||||
|     dispatch(fetchRelationships([id])); |     dispatch(fetchRelationships([id])); | ||||||
|  | @ -74,9 +94,16 @@ export function fetchAccount(id) { | ||||||
| 
 | 
 | ||||||
|     dispatch(fetchAccountRequest(id)); |     dispatch(fetchAccountRequest(id)); | ||||||
| 
 | 
 | ||||||
|     api(getState).get(`/api/v1/accounts/${id}`).then(response => { |     asyncDB.then(db => getFromDB( | ||||||
|       dispatch(fetchAccountSuccess(response.data)); |       dispatch, | ||||||
|     }).catch(error => { |       getState, | ||||||
|  |       db.transaction('accounts', 'read').objectStore('accounts').index('id'), | ||||||
|  |       id | ||||||
|  |     )).catch(() => api(getState).get(`/api/v1/accounts/${id}`).then(response => { | ||||||
|  |       dispatch(importFetchedAccount(response.data)); | ||||||
|  |     })).then(() => { | ||||||
|  |       dispatch(fetchAccountSuccess()); | ||||||
|  |     }, error => { | ||||||
|       dispatch(fetchAccountFail(id, error)); |       dispatch(fetchAccountFail(id, error)); | ||||||
|     }); |     }); | ||||||
|   }; |   }; | ||||||
|  | @ -89,10 +116,9 @@ export function fetchAccountRequest(id) { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function fetchAccountSuccess(account) { | export function fetchAccountSuccess() { | ||||||
|   return { |   return { | ||||||
|     type: ACCOUNT_FETCH_SUCCESS, |     type: ACCOUNT_FETCH_SUCCESS, | ||||||
|     account, |  | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -319,6 +345,7 @@ export function fetchFollowers(id) { | ||||||
|     api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { |     api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
| 
 | 
 | ||||||
|  |       dispatch(importFetchedAccounts(response.data)); | ||||||
|       dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); |       dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); | ||||||
|       dispatch(fetchRelationships(response.data.map(item => item.id))); |       dispatch(fetchRelationships(response.data.map(item => item.id))); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|  | @ -364,6 +391,7 @@ export function expandFollowers(id) { | ||||||
|     api(getState).get(url).then(response => { |     api(getState).get(url).then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
| 
 | 
 | ||||||
|  |       dispatch(importFetchedAccounts(response.data)); | ||||||
|       dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); |       dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); | ||||||
|       dispatch(fetchRelationships(response.data.map(item => item.id))); |       dispatch(fetchRelationships(response.data.map(item => item.id))); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|  | @ -403,6 +431,7 @@ export function fetchFollowing(id) { | ||||||
|     api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { |     api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
| 
 | 
 | ||||||
|  |       dispatch(importFetchedAccounts(response.data)); | ||||||
|       dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); |       dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); | ||||||
|       dispatch(fetchRelationships(response.data.map(item => item.id))); |       dispatch(fetchRelationships(response.data.map(item => item.id))); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|  | @ -448,6 +477,7 @@ export function expandFollowing(id) { | ||||||
|     api(getState).get(url).then(response => { |     api(getState).get(url).then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
| 
 | 
 | ||||||
|  |       dispatch(importFetchedAccounts(response.data)); | ||||||
|       dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); |       dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); | ||||||
|       dispatch(fetchRelationships(response.data.map(item => item.id))); |       dispatch(fetchRelationships(response.data.map(item => item.id))); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|  | @ -529,6 +559,7 @@ export function fetchFollowRequests() { | ||||||
| 
 | 
 | ||||||
|     api(getState).get('/api/v1/follow_requests').then(response => { |     api(getState).get('/api/v1/follow_requests').then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
|  |       dispatch(importFetchedAccounts(response.data)); | ||||||
|       dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); |       dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); | ||||||
|     }).catch(error => dispatch(fetchFollowRequestsFail(error))); |     }).catch(error => dispatch(fetchFollowRequestsFail(error))); | ||||||
|   }; |   }; | ||||||
|  | @ -567,6 +598,7 @@ export function expandFollowRequests() { | ||||||
| 
 | 
 | ||||||
|     api(getState).get(url).then(response => { |     api(getState).get(url).then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
|  |       dispatch(importFetchedAccounts(response.data)); | ||||||
|       dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); |       dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); | ||||||
|     }).catch(error => dispatch(expandFollowRequestsFail(error))); |     }).catch(error => dispatch(expandFollowRequestsFail(error))); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  | @ -1,5 +1,6 @@ | ||||||
| import api, { getLinks } from '../api'; | import api, { getLinks } from '../api'; | ||||||
| import { fetchRelationships } from './accounts'; | import { fetchRelationships } from './accounts'; | ||||||
|  | import { importFetchedAccounts } from './importer'; | ||||||
| 
 | 
 | ||||||
| export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; | export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; | ||||||
| export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; | export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; | ||||||
|  | @ -15,6 +16,7 @@ export function fetchBlocks() { | ||||||
| 
 | 
 | ||||||
|     api(getState).get('/api/v1/blocks').then(response => { |     api(getState).get('/api/v1/blocks').then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
|  |       dispatch(importFetchedAccounts(response.data)); | ||||||
|       dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null)); |       dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null)); | ||||||
|       dispatch(fetchRelationships(response.data.map(item => item.id))); |       dispatch(fetchRelationships(response.data.map(item => item.id))); | ||||||
|     }).catch(error => dispatch(fetchBlocksFail(error))); |     }).catch(error => dispatch(fetchBlocksFail(error))); | ||||||
|  | @ -54,6 +56,7 @@ export function expandBlocks() { | ||||||
| 
 | 
 | ||||||
|     api(getState).get(url).then(response => { |     api(getState).get(url).then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
|  |       dispatch(importFetchedAccounts(response.data)); | ||||||
|       dispatch(expandBlocksSuccess(response.data, next ? next.uri : null)); |       dispatch(expandBlocksSuccess(response.data, next ? next.uri : null)); | ||||||
|       dispatch(fetchRelationships(response.data.map(item => item.id))); |       dispatch(fetchRelationships(response.data.map(item => item.id))); | ||||||
|     }).catch(error => dispatch(expandBlocksFail(error))); |     }).catch(error => dispatch(expandBlocksFail(error))); | ||||||
|  |  | ||||||
|  | @ -4,13 +4,8 @@ import { throttle } from 'lodash'; | ||||||
| import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; | import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; | ||||||
| import { tagHistory } from '../settings'; | import { tagHistory } from '../settings'; | ||||||
| import { useEmoji } from './emojis'; | import { useEmoji } from './emojis'; | ||||||
| 
 | import { importFetchedAccounts } from './importer'; | ||||||
| import { | import { updateTimeline } from './timelines'; | ||||||
|   updateTimeline, |  | ||||||
|   refreshHomeTimeline, |  | ||||||
|   refreshCommunityTimeline, |  | ||||||
|   refreshPublicTimeline, |  | ||||||
| } from './timelines'; |  | ||||||
| 
 | 
 | ||||||
| let cancelFetchComposeSuggestionsAccounts; | let cancelFetchComposeSuggestionsAccounts; | ||||||
| 
 | 
 | ||||||
|  | @ -124,19 +119,17 @@ export function submitCompose() { | ||||||
| 
 | 
 | ||||||
|       // To make the app more responsive, immediately get the status into the columns
 |       // To make the app more responsive, immediately get the status into the columns
 | ||||||
| 
 | 
 | ||||||
|       const insertOrRefresh = (timelineId, refreshAction) => { |       const insertIfOnline = (timelineId) => { | ||||||
|         if (getState().getIn(['timelines', timelineId, 'online'])) { |         if (getState().getIn(['timelines', timelineId, 'items', 0]) !== null) { | ||||||
|           dispatch(updateTimeline(timelineId, { ...response.data })); |           dispatch(updateTimeline(timelineId, { ...response.data })); | ||||||
|         } else if (getState().getIn(['timelines', timelineId, 'loaded'])) { |  | ||||||
|           dispatch(refreshAction()); |  | ||||||
|         } |         } | ||||||
|       }; |       }; | ||||||
| 
 | 
 | ||||||
|       insertOrRefresh('home', refreshHomeTimeline); |       insertIfOnline('home'); | ||||||
| 
 | 
 | ||||||
|       if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { |       if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { | ||||||
|         insertOrRefresh('community', refreshCommunityTimeline); |         insertIfOnline('community'); | ||||||
|         insertOrRefresh('public', refreshPublicTimeline); |         insertIfOnline('public'); | ||||||
|       } |       } | ||||||
|     }).catch(function (error) { |     }).catch(function (error) { | ||||||
|       dispatch(submitComposeFail(error)); |       dispatch(submitComposeFail(error)); | ||||||
|  | @ -282,6 +275,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => | ||||||
|       limit: 4, |       limit: 4, | ||||||
|     }, |     }, | ||||||
|   }).then(response => { |   }).then(response => { | ||||||
|  |     dispatch(importFetchedAccounts(response.data)); | ||||||
|     dispatch(readyComposeSuggestionsAccounts(token, response.data)); |     dispatch(readyComposeSuggestionsAccounts(token, response.data)); | ||||||
|   }); |   }); | ||||||
| }, 200, { leading: true, trailing: true }); | }, 200, { leading: true, trailing: true }); | ||||||
|  |  | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| import api, { getLinks } from '../api'; | import api, { getLinks } from '../api'; | ||||||
|  | import { importFetchedStatuses } from './importer'; | ||||||
| 
 | 
 | ||||||
| export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; | export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; | ||||||
| export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; | export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; | ||||||
|  | @ -18,6 +19,7 @@ export function fetchFavouritedStatuses() { | ||||||
| 
 | 
 | ||||||
|     api(getState).get('/api/v1/favourites').then(response => { |     api(getState).get('/api/v1/favourites').then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
|  |       dispatch(importFetchedStatuses(response.data)); | ||||||
|       dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); |       dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(fetchFavouritedStatusesFail(error)); |       dispatch(fetchFavouritedStatusesFail(error)); | ||||||
|  | @ -58,6 +60,7 @@ export function expandFavouritedStatuses() { | ||||||
| 
 | 
 | ||||||
|     api(getState).get(url).then(response => { |     api(getState).get(url).then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
|  |       dispatch(importFetchedStatuses(response.data)); | ||||||
|       dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); |       dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(expandFavouritedStatusesFail(error)); |       dispatch(expandFavouritedStatusesFail(error)); | ||||||
|  |  | ||||||
							
								
								
									
										76
									
								
								app/javascript/mastodon/actions/importer/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								app/javascript/mastodon/actions/importer/index.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,76 @@ | ||||||
|  | import { putAccounts, putStatuses } from '../../db/modifier'; | ||||||
|  | import { normalizeAccount, normalizeStatus } from './normalizer'; | ||||||
|  | 
 | ||||||
|  | export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; | ||||||
|  | export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; | ||||||
|  | export const STATUS_IMPORT = 'STATUS_IMPORT'; | ||||||
|  | export const STATUSES_IMPORT = 'STATUSES_IMPORT'; | ||||||
|  | 
 | ||||||
|  | function pushUnique(array, object) { | ||||||
|  |   if (array.every(element => element.id !== object.id)) { | ||||||
|  |     array.push(object); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function importAccount(account) { | ||||||
|  |   return { type: ACCOUNT_IMPORT, account }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function importAccounts(accounts) { | ||||||
|  |   return { type: ACCOUNTS_IMPORT, accounts }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function importStatus(status) { | ||||||
|  |   return { type: STATUS_IMPORT, status }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function importStatuses(statuses) { | ||||||
|  |   return { type: STATUSES_IMPORT, statuses }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function importFetchedAccount(account) { | ||||||
|  |   return importFetchedAccounts([account]); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function importFetchedAccounts(accounts) { | ||||||
|  |   const normalAccounts = []; | ||||||
|  | 
 | ||||||
|  |   function processAccount(account) { | ||||||
|  |     pushUnique(normalAccounts, normalizeAccount(account)); | ||||||
|  | 
 | ||||||
|  |     if (account.moved) { | ||||||
|  |       processAccount(account); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   accounts.forEach(processAccount); | ||||||
|  |   putAccounts(normalAccounts); | ||||||
|  | 
 | ||||||
|  |   return importAccounts(normalAccounts); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function importFetchedStatus(status) { | ||||||
|  |   return importFetchedStatuses([status]); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function importFetchedStatuses(statuses) { | ||||||
|  |   return (dispatch, getState) => { | ||||||
|  |     const accounts = []; | ||||||
|  |     const normalStatuses = []; | ||||||
|  | 
 | ||||||
|  |     function processStatus(status) { | ||||||
|  |       pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]))); | ||||||
|  |       pushUnique(accounts, status.account); | ||||||
|  | 
 | ||||||
|  |       if (status.reblog && status.reblog.id) { | ||||||
|  |         processStatus(status.reblog); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     statuses.forEach(processStatus); | ||||||
|  |     putStatuses(normalStatuses); | ||||||
|  | 
 | ||||||
|  |     dispatch(importFetchedAccounts(accounts)); | ||||||
|  |     dispatch(importStatuses(normalStatuses)); | ||||||
|  |   }; | ||||||
|  | } | ||||||
							
								
								
									
										46
									
								
								app/javascript/mastodon/actions/importer/normalizer.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								app/javascript/mastodon/actions/importer/normalizer.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,46 @@ | ||||||
|  | import escapeTextContentForBrowser from 'escape-html'; | ||||||
|  | import emojify from '../../features/emoji/emoji'; | ||||||
|  | 
 | ||||||
|  | const domParser = new DOMParser(); | ||||||
|  | 
 | ||||||
|  | export function normalizeAccount(account) { | ||||||
|  |   account = { ...account }; | ||||||
|  | 
 | ||||||
|  |   const displayName = account.display_name.length === 0 ? account.username : account.display_name; | ||||||
|  |   account.display_name_html = emojify(escapeTextContentForBrowser(displayName)); | ||||||
|  |   account.note_emojified = emojify(account.note); | ||||||
|  | 
 | ||||||
|  |   return account; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function normalizeStatus(status, normalOldStatus) { | ||||||
|  |   const normalStatus   = { ...status }; | ||||||
|  |   normalStatus.account = status.account.id; | ||||||
|  | 
 | ||||||
|  |   if (status.reblog && status.reblog.id) { | ||||||
|  |     normalStatus.reblog = status.reblog.id; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Only calculate these values when status first encountered
 | ||||||
|  |   // Otherwise keep the ones already in the reducer
 | ||||||
|  |   if (normalOldStatus) { | ||||||
|  |     normalStatus.search_index = normalOldStatus.get('search_index'); | ||||||
|  |     normalStatus.contentHtml = normalOldStatus.get('contentHtml'); | ||||||
|  |     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); | ||||||
|  |     normalStatus.hidden = normalOldStatus.get('hidden'); | ||||||
|  |   } else { | ||||||
|  |     const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); | ||||||
|  | 
 | ||||||
|  |     const emojiMap = normalStatus.emojis.reduce((obj, emoji) => { | ||||||
|  |       obj[`:${emoji.shortcode}:`] = emoji; | ||||||
|  |       return obj; | ||||||
|  |     }, {}); | ||||||
|  | 
 | ||||||
|  |     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; | ||||||
|  |     normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap); | ||||||
|  |     normalStatus.spoilerHtml  = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap); | ||||||
|  |     normalStatus.hidden       = normalStatus.sensitive; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return normalStatus; | ||||||
|  | } | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| import api from '../api'; | import api from '../api'; | ||||||
|  | import { importFetchedAccounts, importFetchedStatus } from './importer'; | ||||||
| 
 | 
 | ||||||
| export const REBLOG_REQUEST = 'REBLOG_REQUEST'; | export const REBLOG_REQUEST = 'REBLOG_REQUEST'; | ||||||
| export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; | export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; | ||||||
|  | @ -39,7 +40,8 @@ export function reblog(status) { | ||||||
|     api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) { |     api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) { | ||||||
|       // The reblog API method returns a new status wrapped around the original. In this case we are only
 |       // The reblog API method returns a new status wrapped around the original. In this case we are only
 | ||||||
|       // interested in how the original is modified, hence passing it skipping the wrapper
 |       // interested in how the original is modified, hence passing it skipping the wrapper
 | ||||||
|       dispatch(reblogSuccess(status, response.data.reblog)); |       dispatch(importFetchedStatus(response.data.reblog)); | ||||||
|  |       dispatch(reblogSuccess(status)); | ||||||
|     }).catch(function (error) { |     }).catch(function (error) { | ||||||
|       dispatch(reblogFail(status, error)); |       dispatch(reblogFail(status, error)); | ||||||
|     }); |     }); | ||||||
|  | @ -51,7 +53,8 @@ export function unreblog(status) { | ||||||
|     dispatch(unreblogRequest(status)); |     dispatch(unreblogRequest(status)); | ||||||
| 
 | 
 | ||||||
|     api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => { |     api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => { | ||||||
|       dispatch(unreblogSuccess(status, response.data)); |       dispatch(importFetchedStatus(response.data)); | ||||||
|  |       dispatch(unreblogSuccess(status)); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(unreblogFail(status, error)); |       dispatch(unreblogFail(status, error)); | ||||||
|     }); |     }); | ||||||
|  | @ -66,11 +69,10 @@ export function reblogRequest(status) { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function reblogSuccess(status, response) { | export function reblogSuccess(status) { | ||||||
|   return { |   return { | ||||||
|     type: REBLOG_SUCCESS, |     type: REBLOG_SUCCESS, | ||||||
|     status: status, |     status: status, | ||||||
|     response: response, |  | ||||||
|     skipLoading: true, |     skipLoading: true, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  | @ -92,11 +94,10 @@ export function unreblogRequest(status) { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function unreblogSuccess(status, response) { | export function unreblogSuccess(status) { | ||||||
|   return { |   return { | ||||||
|     type: UNREBLOG_SUCCESS, |     type: UNREBLOG_SUCCESS, | ||||||
|     status: status, |     status: status, | ||||||
|     response: response, |  | ||||||
|     skipLoading: true, |     skipLoading: true, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  | @ -115,7 +116,8 @@ export function favourite(status) { | ||||||
|     dispatch(favouriteRequest(status)); |     dispatch(favouriteRequest(status)); | ||||||
| 
 | 
 | ||||||
|     api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) { |     api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) { | ||||||
|       dispatch(favouriteSuccess(status, response.data)); |       dispatch(importFetchedStatus(response.data)); | ||||||
|  |       dispatch(favouriteSuccess(status)); | ||||||
|     }).catch(function (error) { |     }).catch(function (error) { | ||||||
|       dispatch(favouriteFail(status, error)); |       dispatch(favouriteFail(status, error)); | ||||||
|     }); |     }); | ||||||
|  | @ -127,7 +129,8 @@ export function unfavourite(status) { | ||||||
|     dispatch(unfavouriteRequest(status)); |     dispatch(unfavouriteRequest(status)); | ||||||
| 
 | 
 | ||||||
|     api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => { |     api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => { | ||||||
|       dispatch(unfavouriteSuccess(status, response.data)); |       dispatch(importFetchedStatus(response.data)); | ||||||
|  |       dispatch(unfavouriteSuccess(status)); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(unfavouriteFail(status, error)); |       dispatch(unfavouriteFail(status, error)); | ||||||
|     }); |     }); | ||||||
|  | @ -142,11 +145,10 @@ export function favouriteRequest(status) { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function favouriteSuccess(status, response) { | export function favouriteSuccess(status) { | ||||||
|   return { |   return { | ||||||
|     type: FAVOURITE_SUCCESS, |     type: FAVOURITE_SUCCESS, | ||||||
|     status: status, |     status: status, | ||||||
|     response: response, |  | ||||||
|     skipLoading: true, |     skipLoading: true, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  | @ -168,11 +170,10 @@ export function unfavouriteRequest(status) { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function unfavouriteSuccess(status, response) { | export function unfavouriteSuccess(status) { | ||||||
|   return { |   return { | ||||||
|     type: UNFAVOURITE_SUCCESS, |     type: UNFAVOURITE_SUCCESS, | ||||||
|     status: status, |     status: status, | ||||||
|     response: response, |  | ||||||
|     skipLoading: true, |     skipLoading: true, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  | @ -191,6 +192,7 @@ export function fetchReblogs(id) { | ||||||
|     dispatch(fetchReblogsRequest(id)); |     dispatch(fetchReblogsRequest(id)); | ||||||
| 
 | 
 | ||||||
|     api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { |     api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { | ||||||
|  |       dispatch(importFetchedAccounts(response.data)); | ||||||
|       dispatch(fetchReblogsSuccess(id, response.data)); |       dispatch(fetchReblogsSuccess(id, response.data)); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(fetchReblogsFail(id, error)); |       dispatch(fetchReblogsFail(id, error)); | ||||||
|  | @ -225,6 +227,7 @@ export function fetchFavourites(id) { | ||||||
|     dispatch(fetchFavouritesRequest(id)); |     dispatch(fetchFavouritesRequest(id)); | ||||||
| 
 | 
 | ||||||
|     api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { |     api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { | ||||||
|  |       dispatch(importFetchedAccounts(response.data)); | ||||||
|       dispatch(fetchFavouritesSuccess(id, response.data)); |       dispatch(fetchFavouritesSuccess(id, response.data)); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(fetchFavouritesFail(id, error)); |       dispatch(fetchFavouritesFail(id, error)); | ||||||
|  | @ -259,7 +262,8 @@ export function pin(status) { | ||||||
|     dispatch(pinRequest(status)); |     dispatch(pinRequest(status)); | ||||||
| 
 | 
 | ||||||
|     api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { |     api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { | ||||||
|       dispatch(pinSuccess(status, response.data)); |       dispatch(importFetchedStatus(response.data)); | ||||||
|  |       dispatch(pinSuccess(status)); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(pinFail(status, error)); |       dispatch(pinFail(status, error)); | ||||||
|     }); |     }); | ||||||
|  | @ -274,11 +278,10 @@ export function pinRequest(status) { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function pinSuccess(status, response) { | export function pinSuccess(status) { | ||||||
|   return { |   return { | ||||||
|     type: PIN_SUCCESS, |     type: PIN_SUCCESS, | ||||||
|     status, |     status, | ||||||
|     response, |  | ||||||
|     skipLoading: true, |     skipLoading: true, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  | @ -297,7 +300,8 @@ export function unpin (status) { | ||||||
|     dispatch(unpinRequest(status)); |     dispatch(unpinRequest(status)); | ||||||
| 
 | 
 | ||||||
|     api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { |     api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { | ||||||
|       dispatch(unpinSuccess(status, response.data)); |       dispatch(importFetchedStatus(response.data)); | ||||||
|  |       dispatch(unpinSuccess(status)); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(unpinFail(status, error)); |       dispatch(unpinFail(status, error)); | ||||||
|     }); |     }); | ||||||
|  | @ -312,11 +316,10 @@ export function unpinRequest(status) { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function unpinSuccess(status, response) { | export function unpinSuccess(status) { | ||||||
|   return { |   return { | ||||||
|     type: UNPIN_SUCCESS, |     type: UNPIN_SUCCESS, | ||||||
|     status, |     status, | ||||||
|     response, |  | ||||||
|     skipLoading: true, |     skipLoading: true, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| import api from '../api'; | import api from '../api'; | ||||||
|  | import { importFetchedAccounts } from './importer'; | ||||||
| 
 | 
 | ||||||
| export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; | export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; | ||||||
| export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; | export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; | ||||||
|  | @ -200,9 +201,10 @@ export const deleteListFail = (id, error) => ({ | ||||||
| export const fetchListAccounts = listId => (dispatch, getState) => { | export const fetchListAccounts = listId => (dispatch, getState) => { | ||||||
|   dispatch(fetchListAccountsRequest(listId)); |   dispatch(fetchListAccountsRequest(listId)); | ||||||
| 
 | 
 | ||||||
|   api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }) |   api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { | ||||||
|     .then(({ data }) => dispatch(fetchListAccountsSuccess(listId, data))) |     dispatch(importFetchedAccounts(data)); | ||||||
|     .catch(err => dispatch(fetchListAccountsFail(listId, err))); |     dispatch(fetchListAccountsSuccess(listId, data)); | ||||||
|  |   }).catch(err => dispatch(fetchListAccountsFail(listId, err))); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const fetchListAccountsRequest = id => ({ | export const fetchListAccountsRequest = id => ({ | ||||||
|  | @ -231,8 +233,10 @@ export const fetchListSuggestions = q => (dispatch, getState) => { | ||||||
|     following: true, |     following: true, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   api(getState).get('/api/v1/accounts/search', { params }) |   api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { | ||||||
|     .then(({ data }) => dispatch(fetchListSuggestionsReady(q, data))); |     dispatch(importFetchedAccounts(data)); | ||||||
|  |     dispatch(fetchListSuggestionsReady(q, data)); | ||||||
|  |   }); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const fetchListSuggestionsReady = (query, accounts) => ({ | export const fetchListSuggestionsReady = (query, accounts) => ({ | ||||||
|  |  | ||||||
|  | @ -1,5 +1,6 @@ | ||||||
| import api, { getLinks } from '../api'; | import api, { getLinks } from '../api'; | ||||||
| import { fetchRelationships } from './accounts'; | import { fetchRelationships } from './accounts'; | ||||||
|  | import { importFetchedAccounts } from './importer'; | ||||||
| import { openModal } from './modal'; | import { openModal } from './modal'; | ||||||
| 
 | 
 | ||||||
| export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; | export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; | ||||||
|  | @ -19,6 +20,7 @@ export function fetchMutes() { | ||||||
| 
 | 
 | ||||||
|     api(getState).get('/api/v1/mutes').then(response => { |     api(getState).get('/api/v1/mutes').then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
|  |       dispatch(importFetchedAccounts(response.data)); | ||||||
|       dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); |       dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); | ||||||
|       dispatch(fetchRelationships(response.data.map(item => item.id))); |       dispatch(fetchRelationships(response.data.map(item => item.id))); | ||||||
|     }).catch(error => dispatch(fetchMutesFail(error))); |     }).catch(error => dispatch(fetchMutesFail(error))); | ||||||
|  | @ -58,6 +60,7 @@ export function expandMutes() { | ||||||
| 
 | 
 | ||||||
|     api(getState).get(url).then(response => { |     api(getState).get(url).then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
|  |       dispatch(importFetchedAccounts(response.data)); | ||||||
|       dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); |       dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); | ||||||
|       dispatch(fetchRelationships(response.data.map(item => item.id))); |       dispatch(fetchRelationships(response.data.map(item => item.id))); | ||||||
|     }).catch(error => dispatch(expandMutesFail(error))); |     }).catch(error => dispatch(expandMutesFail(error))); | ||||||
|  |  | ||||||
|  | @ -1,15 +1,16 @@ | ||||||
| import api, { getLinks } from '../api'; | import api, { getLinks } from '../api'; | ||||||
| import { List as ImmutableList } from 'immutable'; |  | ||||||
| import IntlMessageFormat from 'intl-messageformat'; | import IntlMessageFormat from 'intl-messageformat'; | ||||||
| import { fetchRelationships } from './accounts'; | import { fetchRelationships } from './accounts'; | ||||||
|  | import { | ||||||
|  |   importFetchedAccount, | ||||||
|  |   importFetchedAccounts, | ||||||
|  |   importFetchedStatus, | ||||||
|  |   importFetchedStatuses, | ||||||
|  | } from './importer'; | ||||||
| import { defineMessages } from 'react-intl'; | import { defineMessages } from 'react-intl'; | ||||||
| 
 | 
 | ||||||
| export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; | export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; | ||||||
| 
 | 
 | ||||||
| export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST'; |  | ||||||
| export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS'; |  | ||||||
| export const NOTIFICATIONS_REFRESH_FAIL    = 'NOTIFICATIONS_REFRESH_FAIL'; |  | ||||||
| 
 |  | ||||||
| export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; | export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; | ||||||
| export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; | export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; | ||||||
| export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL'; | export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL'; | ||||||
|  | @ -41,11 +42,12 @@ export function updateNotifications(notification, intlMessages, intlLocale) { | ||||||
|     const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); |     const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); | ||||||
|     const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); |     const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); | ||||||
| 
 | 
 | ||||||
|  |     dispatch(importFetchedAccount(notification.account)); | ||||||
|  |     dispatch(importFetchedStatus(notification.status)); | ||||||
|  | 
 | ||||||
|     dispatch({ |     dispatch({ | ||||||
|       type: NOTIFICATIONS_UPDATE, |       type: NOTIFICATIONS_UPDATE, | ||||||
|       notification, |       notification, | ||||||
|       account: notification.account, |  | ||||||
|       status: notification.status, |  | ||||||
|       meta: playSound ? { sound: 'boop' } : undefined, |       meta: playSound ? { sound: 'boop' } : undefined, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  | @ -67,73 +69,14 @@ export function updateNotifications(notification, intlMessages, intlLocale) { | ||||||
| 
 | 
 | ||||||
| const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); | const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); | ||||||
| 
 | 
 | ||||||
| export function refreshNotifications() { | export function expandNotifications({ maxId } = {}) { | ||||||
|   return (dispatch, getState) => { |   return (dispatch, getState) => { | ||||||
|     const params = {}; |     if (getState().getIn(['notifications', 'isLoading'])) { | ||||||
|     const ids    = getState().getIn(['notifications', 'items']); |  | ||||||
| 
 |  | ||||||
|     let skipLoading = false; |  | ||||||
| 
 |  | ||||||
|     if (ids.size > 0) { |  | ||||||
|       params.since_id = ids.first().get('id'); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     if (getState().getIn(['notifications', 'loaded'])) { |  | ||||||
|       skipLoading = true; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     params.exclude_types = excludeTypesFromSettings(getState()); |  | ||||||
| 
 |  | ||||||
|     dispatch(refreshNotificationsRequest(skipLoading)); |  | ||||||
| 
 |  | ||||||
|     api(getState).get('/api/v1/notifications', { params }).then(response => { |  | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |  | ||||||
| 
 |  | ||||||
|       dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null)); |  | ||||||
|       fetchRelatedRelationships(dispatch, response.data); |  | ||||||
|     }).catch(error => { |  | ||||||
|       dispatch(refreshNotificationsFail(error, skipLoading)); |  | ||||||
|     }); |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export function refreshNotificationsRequest(skipLoading) { |  | ||||||
|   return { |  | ||||||
|     type: NOTIFICATIONS_REFRESH_REQUEST, |  | ||||||
|     skipLoading, |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export function refreshNotificationsSuccess(notifications, skipLoading, next) { |  | ||||||
|   return { |  | ||||||
|     type: NOTIFICATIONS_REFRESH_SUCCESS, |  | ||||||
|     notifications, |  | ||||||
|     accounts: notifications.map(item => item.account), |  | ||||||
|     statuses: notifications.map(item => item.status).filter(status => !!status), |  | ||||||
|     skipLoading, |  | ||||||
|     next, |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export function refreshNotificationsFail(error, skipLoading) { |  | ||||||
|   return { |  | ||||||
|     type: NOTIFICATIONS_REFRESH_FAIL, |  | ||||||
|     error, |  | ||||||
|     skipLoading, |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export function expandNotifications() { |  | ||||||
|   return (dispatch, getState) => { |  | ||||||
|     const items  = getState().getIn(['notifications', 'items'], ImmutableList()); |  | ||||||
| 
 |  | ||||||
|     if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) { |  | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const params = { |     const params = { | ||||||
|       max_id: items.last().get('id'), |       max_id: maxId, | ||||||
|       limit: 20, |  | ||||||
|       exclude_types: excludeTypesFromSettings(getState()), |       exclude_types: excludeTypesFromSettings(getState()), | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|  | @ -141,6 +84,10 @@ export function expandNotifications() { | ||||||
| 
 | 
 | ||||||
|     api(getState).get('/api/v1/notifications', { params }).then(response => { |     api(getState).get('/api/v1/notifications', { params }).then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
|  | 
 | ||||||
|  |       dispatch(importFetchedAccounts(response.data.map(item => item.account))); | ||||||
|  |       dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); | ||||||
|  | 
 | ||||||
|       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); |       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); | ||||||
|       fetchRelatedRelationships(dispatch, response.data); |       fetchRelatedRelationships(dispatch, response.data); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|  | @ -159,8 +106,6 @@ export function expandNotificationsSuccess(notifications, next) { | ||||||
|   return { |   return { | ||||||
|     type: NOTIFICATIONS_EXPAND_SUCCESS, |     type: NOTIFICATIONS_EXPAND_SUCCESS, | ||||||
|     notifications, |     notifications, | ||||||
|     accounts: notifications.map(item => item.account), |  | ||||||
|     statuses: notifications.map(item => item.status).filter(status => !!status), |  | ||||||
|     next, |     next, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| import api from '../api'; | import api from '../api'; | ||||||
|  | import { importFetchedStatuses } from './importer'; | ||||||
| 
 | 
 | ||||||
| export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; | export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; | ||||||
| export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; | export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; | ||||||
|  | @ -11,6 +12,7 @@ export function fetchPinnedStatuses() { | ||||||
|     dispatch(fetchPinnedStatusesRequest()); |     dispatch(fetchPinnedStatusesRequest()); | ||||||
| 
 | 
 | ||||||
|     api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { |     api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { | ||||||
|  |       dispatch(importFetchedStatuses(response.data)); | ||||||
|       dispatch(fetchPinnedStatusesSuccess(response.data, null)); |       dispatch(fetchPinnedStatusesSuccess(response.data, null)); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(fetchPinnedStatusesFail(error)); |       dispatch(fetchPinnedStatusesFail(error)); | ||||||
|  |  | ||||||
|  | @ -1,5 +1,6 @@ | ||||||
| import api from '../api'; | import api from '../api'; | ||||||
| import { fetchRelationships } from './accounts'; | import { fetchRelationships } from './accounts'; | ||||||
|  | import { importFetchedAccounts, importFetchedStatuses } from './importer'; | ||||||
| 
 | 
 | ||||||
| export const SEARCH_CHANGE = 'SEARCH_CHANGE'; | export const SEARCH_CHANGE = 'SEARCH_CHANGE'; | ||||||
| export const SEARCH_CLEAR  = 'SEARCH_CLEAR'; | export const SEARCH_CLEAR  = 'SEARCH_CLEAR'; | ||||||
|  | @ -38,6 +39,14 @@ export function submitSearch() { | ||||||
|         resolve: true, |         resolve: true, | ||||||
|       }, |       }, | ||||||
|     }).then(response => { |     }).then(response => { | ||||||
|  |       if (response.data.accounts) { | ||||||
|  |         dispatch(importFetchedAccounts(response.data.accounts)); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       if (response.data.statuses) { | ||||||
|  |         dispatch(importFetchedStatuses(response.data.statuses)); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       dispatch(fetchSearchSuccess(response.data)); |       dispatch(fetchSearchSuccess(response.data)); | ||||||
|       dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); |       dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|  | @ -56,8 +65,6 @@ export function fetchSearchSuccess(results) { | ||||||
|   return { |   return { | ||||||
|     type: SEARCH_FETCH_SUCCESS, |     type: SEARCH_FETCH_SUCCESS, | ||||||
|     results, |     results, | ||||||
|     accounts: results.accounts, |  | ||||||
|     statuses: results.statuses, |  | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,7 +1,10 @@ | ||||||
| import api from '../api'; | import api from '../api'; | ||||||
|  | import asyncDB from '../db/async'; | ||||||
|  | import { evictStatus } from '../db/modifier'; | ||||||
| 
 | 
 | ||||||
| import { deleteFromTimelines } from './timelines'; | import { deleteFromTimelines } from './timelines'; | ||||||
| import { fetchStatusCard } from './cards'; | import { fetchStatusCard } from './cards'; | ||||||
|  | import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus } from './importer'; | ||||||
| 
 | 
 | ||||||
| export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; | export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; | ||||||
| export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; | export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; | ||||||
|  | @ -34,6 +37,48 @@ export function fetchStatusRequest(id, skipLoading) { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | function getFromDB(dispatch, getState, accountIndex, index, id) { | ||||||
|  |   return new Promise((resolve, reject) => { | ||||||
|  |     const request = index.get(id); | ||||||
|  | 
 | ||||||
|  |     request.onerror = reject; | ||||||
|  | 
 | ||||||
|  |     request.onsuccess = () => { | ||||||
|  |       const promises = []; | ||||||
|  | 
 | ||||||
|  |       if (!request.result) { | ||||||
|  |         reject(); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       dispatch(importStatus(request.result)); | ||||||
|  | 
 | ||||||
|  |       if (getState().getIn(['accounts', request.result.account], null) === null) { | ||||||
|  |         promises.push(new Promise((accountResolve, accountReject) => { | ||||||
|  |           const accountRequest = accountIndex.get(request.result.account); | ||||||
|  | 
 | ||||||
|  |           accountRequest.onerror = accountReject; | ||||||
|  |           accountRequest.onsuccess = () => { | ||||||
|  |             if (!request.result) { | ||||||
|  |               accountReject(); | ||||||
|  |               return; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             dispatch(importAccount(accountRequest.result)); | ||||||
|  |             accountResolve(); | ||||||
|  |           }; | ||||||
|  |         })); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       if (request.result.reblog && getState().getIn(['statuses', request.result.reblog], null) === null) { | ||||||
|  |         promises.push(getFromDB(dispatch, getState, accountIndex, index, request.result.reblog)); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       resolve(Promise.all(promises)); | ||||||
|  |     }; | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export function fetchStatus(id) { | export function fetchStatus(id) { | ||||||
|   return (dispatch, getState) => { |   return (dispatch, getState) => { | ||||||
|     const skipLoading = getState().getIn(['statuses', id], null) !== null; |     const skipLoading = getState().getIn(['statuses', id], null) !== null; | ||||||
|  | @ -47,18 +92,26 @@ export function fetchStatus(id) { | ||||||
| 
 | 
 | ||||||
|     dispatch(fetchStatusRequest(id, skipLoading)); |     dispatch(fetchStatusRequest(id, skipLoading)); | ||||||
| 
 | 
 | ||||||
|     api(getState).get(`/api/v1/statuses/${id}`).then(response => { |     asyncDB.then(db => { | ||||||
|       dispatch(fetchStatusSuccess(response.data, skipLoading)); |       const transaction = db.transaction(['accounts', 'statuses'], 'read'); | ||||||
|     }).catch(error => { |       const accountIndex = transaction.objectStore('accounts').index('id'); | ||||||
|  |       const index = transaction.objectStore('statuses').index('id'); | ||||||
|  | 
 | ||||||
|  |       return getFromDB(dispatch, getState, accountIndex, index, id); | ||||||
|  |     }).then(() => { | ||||||
|  |       dispatch(fetchStatusSuccess(skipLoading)); | ||||||
|  |     }, () => api(getState).get(`/api/v1/statuses/${id}`).then(response => { | ||||||
|  |       dispatch(importFetchedStatus(response.data)); | ||||||
|  |       dispatch(fetchStatusSuccess(skipLoading)); | ||||||
|  |     })).catch(error => { | ||||||
|       dispatch(fetchStatusFail(id, error, skipLoading)); |       dispatch(fetchStatusFail(id, error, skipLoading)); | ||||||
|     }); |     }); | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function fetchStatusSuccess(status, skipLoading) { | export function fetchStatusSuccess(skipLoading) { | ||||||
|   return { |   return { | ||||||
|     type: STATUS_FETCH_SUCCESS, |     type: STATUS_FETCH_SUCCESS, | ||||||
|     status, |  | ||||||
|     skipLoading, |     skipLoading, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  | @ -78,6 +131,7 @@ export function deleteStatus(id) { | ||||||
|     dispatch(deleteStatusRequest(id)); |     dispatch(deleteStatusRequest(id)); | ||||||
| 
 | 
 | ||||||
|     api(getState).delete(`/api/v1/statuses/${id}`).then(() => { |     api(getState).delete(`/api/v1/statuses/${id}`).then(() => { | ||||||
|  |       evictStatus(id); | ||||||
|       dispatch(deleteStatusSuccess(id)); |       dispatch(deleteStatusSuccess(id)); | ||||||
|       dispatch(deleteFromTimelines(id)); |       dispatch(deleteFromTimelines(id)); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|  | @ -113,6 +167,7 @@ export function fetchContext(id) { | ||||||
|     dispatch(fetchContextRequest(id)); |     dispatch(fetchContextRequest(id)); | ||||||
| 
 | 
 | ||||||
|     api(getState).get(`/api/v1/statuses/${id}/context`).then(response => { |     api(getState).get(`/api/v1/statuses/${id}/context`).then(response => { | ||||||
|  |       dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants))); | ||||||
|       dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); |       dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); | ||||||
| 
 | 
 | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|  |  | ||||||
|  | @ -1,5 +1,6 @@ | ||||||
| import { Iterable, fromJS } from 'immutable'; | import { Iterable, fromJS } from 'immutable'; | ||||||
| import { hydrateCompose } from './compose'; | import { hydrateCompose } from './compose'; | ||||||
|  | import { importFetchedAccounts } from './importer'; | ||||||
| 
 | 
 | ||||||
| export const STORE_HYDRATE = 'STORE_HYDRATE'; | export const STORE_HYDRATE = 'STORE_HYDRATE'; | ||||||
| export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; | export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; | ||||||
|  | @ -18,5 +19,6 @@ export function hydrateStore(rawState) { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     dispatch(hydrateCompose()); |     dispatch(hydrateCompose()); | ||||||
|  |     dispatch(importFetchedAccounts(Object.values(rawState.accounts))); | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -2,11 +2,10 @@ import { connectStream } from '../stream'; | ||||||
| import { | import { | ||||||
|   updateTimeline, |   updateTimeline, | ||||||
|   deleteFromTimelines, |   deleteFromTimelines, | ||||||
|   refreshHomeTimeline, |   expandHomeTimeline, | ||||||
|   connectTimeline, |  | ||||||
|   disconnectTimeline, |   disconnectTimeline, | ||||||
| } from './timelines'; | } from './timelines'; | ||||||
| import { updateNotifications, refreshNotifications } from './notifications'; | import { updateNotifications, expandNotifications } from './notifications'; | ||||||
| import { getLocale } from '../locales'; | import { getLocale } from '../locales'; | ||||||
| 
 | 
 | ||||||
| const { messages } = getLocale(); | const { messages } = getLocale(); | ||||||
|  | @ -16,10 +15,6 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) | ||||||
|   return connectStream (path, pollingRefresh, (dispatch, getState) => { |   return connectStream (path, pollingRefresh, (dispatch, getState) => { | ||||||
|     const locale = getState().getIn(['meta', 'locale']); |     const locale = getState().getIn(['meta', 'locale']); | ||||||
|     return { |     return { | ||||||
|       onConnect() { |  | ||||||
|         dispatch(connectTimeline(timelineId)); |  | ||||||
|       }, |  | ||||||
| 
 |  | ||||||
|       onDisconnect() { |       onDisconnect() { | ||||||
|         dispatch(disconnectTimeline(timelineId)); |         dispatch(disconnectTimeline(timelineId)); | ||||||
|       }, |       }, | ||||||
|  | @ -42,8 +37,8 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function refreshHomeTimelineAndNotification (dispatch) { | function refreshHomeTimelineAndNotification (dispatch) { | ||||||
|   dispatch(refreshHomeTimeline()); |   dispatch(expandHomeTimeline()); | ||||||
|   dispatch(refreshNotifications()); |   dispatch(expandNotifications()); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); | export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); | ||||||
|  |  | ||||||
|  | @ -1,35 +1,20 @@ | ||||||
|  | import { importFetchedStatus, importFetchedStatuses } from './importer'; | ||||||
| import api, { getLinks } from '../api'; | import api, { getLinks } from '../api'; | ||||||
| import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | import { Map as ImmutableMap } from 'immutable'; | ||||||
| 
 | 
 | ||||||
| export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE'; | export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE'; | ||||||
| export const TIMELINE_DELETE  = 'TIMELINE_DELETE'; | export const TIMELINE_DELETE  = 'TIMELINE_DELETE'; | ||||||
| 
 | 
 | ||||||
| export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST'; |  | ||||||
| export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS'; |  | ||||||
| export const TIMELINE_REFRESH_FAIL    = 'TIMELINE_REFRESH_FAIL'; |  | ||||||
| 
 |  | ||||||
| export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; | export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; | ||||||
| export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; | export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; | ||||||
| export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL'; | export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL'; | ||||||
| 
 | 
 | ||||||
| export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; | export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; | ||||||
| 
 | 
 | ||||||
| export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT'; |  | ||||||
| export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; | export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; | ||||||
| 
 | 
 | ||||||
| export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE'; | export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE'; | ||||||
| 
 | 
 | ||||||
| export function refreshTimelineSuccess(timeline, statuses, skipLoading, next, partial) { |  | ||||||
|   return { |  | ||||||
|     type: TIMELINE_REFRESH_SUCCESS, |  | ||||||
|     timeline, |  | ||||||
|     statuses, |  | ||||||
|     skipLoading, |  | ||||||
|     next, |  | ||||||
|     partial, |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export function updateTimeline(timeline, status) { | export function updateTimeline(timeline, status) { | ||||||
|   return (dispatch, getState) => { |   return (dispatch, getState) => { | ||||||
|     const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; |     const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; | ||||||
|  | @ -44,6 +29,8 @@ export function updateTimeline(timeline, status) { | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     dispatch(importFetchedStatus(status)); | ||||||
|  | 
 | ||||||
|     dispatch({ |     dispatch({ | ||||||
|       type: TIMELINE_UPDATE, |       type: TIMELINE_UPDATE, | ||||||
|       timeline, |       timeline, | ||||||
|  | @ -77,95 +64,34 @@ export function deleteFromTimelines(id) { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function refreshTimelineRequest(timeline, skipLoading) { |  | ||||||
|   return { |  | ||||||
|     type: TIMELINE_REFRESH_REQUEST, |  | ||||||
|     timeline, |  | ||||||
|     skipLoading, |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export function refreshTimeline(timelineId, path, params = {}) { |  | ||||||
|   return function (dispatch, getState) { |  | ||||||
|     const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); |  | ||||||
| 
 |  | ||||||
|     if (timeline.get('isLoading') || (timeline.get('online') && !timeline.get('isPartial'))) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const ids      = timeline.get('items', ImmutableList()); |  | ||||||
|     const newestId = ids.size > 0 ? ids.first() : null; |  | ||||||
| 
 |  | ||||||
|     let skipLoading = timeline.get('loaded'); |  | ||||||
| 
 |  | ||||||
|     if (newestId !== null) { |  | ||||||
|       params.since_id = newestId; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     dispatch(refreshTimelineRequest(timelineId, skipLoading)); |  | ||||||
| 
 |  | ||||||
|     api(getState).get(path, { params }).then(response => { |  | ||||||
|       if (response.status === 206) { |  | ||||||
|         dispatch(refreshTimelineSuccess(timelineId, [], skipLoading, null, true)); |  | ||||||
|       } else { |  | ||||||
|         const next = getLinks(response).refs.find(link => link.rel === 'next'); |  | ||||||
|         dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null, false)); |  | ||||||
|       } |  | ||||||
|     }).catch(error => { |  | ||||||
|       dispatch(refreshTimelineFail(timelineId, error, skipLoading)); |  | ||||||
|     }); |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export const refreshHomeTimeline            = () => refreshTimeline('home', '/api/v1/timelines/home'); |  | ||||||
| export const refreshPublicTimeline          = () => refreshTimeline('public', '/api/v1/timelines/public'); |  | ||||||
| export const refreshCommunityTimeline       = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); |  | ||||||
| export const refreshAccountTimeline         = (accountId, withReplies) => refreshTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies }); |  | ||||||
| export const refreshAccountFeaturedTimeline = accountId => refreshTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); |  | ||||||
| export const refreshAccountMediaTimeline    = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); |  | ||||||
| export const refreshHashtagTimeline         = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); |  | ||||||
| export const refreshListTimeline            = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); |  | ||||||
| 
 |  | ||||||
| export function refreshTimelineFail(timeline, error, skipLoading) { |  | ||||||
|   return { |  | ||||||
|     type: TIMELINE_REFRESH_FAIL, |  | ||||||
|     timeline, |  | ||||||
|     error, |  | ||||||
|     skipLoading, |  | ||||||
|     skipAlert: error.response && error.response.status === 404, |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export function expandTimeline(timelineId, path, params = {}) { | export function expandTimeline(timelineId, path, params = {}) { | ||||||
|   return (dispatch, getState) => { |   return (dispatch, getState) => { | ||||||
|     const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); |     const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); | ||||||
|     const ids      = timeline.get('items', ImmutableList()); |  | ||||||
| 
 | 
 | ||||||
|     if (timeline.get('isLoading') || ids.size === 0) { |     if (timeline.get('isLoading')) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     params.max_id = ids.last(); |  | ||||||
|     params.limit  = 10; |  | ||||||
| 
 |  | ||||||
|     dispatch(expandTimelineRequest(timelineId)); |     dispatch(expandTimelineRequest(timelineId)); | ||||||
| 
 | 
 | ||||||
|     api(getState).get(path, { params }).then(response => { |     api(getState).get(path, { params }).then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
|       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null)); |       dispatch(importFetchedStatuses(response.data)); | ||||||
|  |       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206)); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(expandTimelineFail(timelineId, error)); |       dispatch(expandTimelineFail(timelineId, error)); | ||||||
|     }); |     }); | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const expandHomeTimeline         = () => expandTimeline('home', '/api/v1/timelines/home'); | export const expandHomeTimeline         = ({ maxId } = {}) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }); | ||||||
| export const expandPublicTimeline       = () => expandTimeline('public', '/api/v1/timelines/public'); | export const expandPublicTimeline       = ({ maxId } = {}) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId }); | ||||||
| export const expandCommunityTimeline    = () => expandTimeline('community', '/api/v1/timelines/public', { local: true }); | export const expandCommunityTimeline    = ({ maxId } = {}) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId }); | ||||||
| export const expandAccountTimeline      = (accountId, withReplies) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies }); | export const expandAccountTimeline      = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); | ||||||
| export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); | export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); | ||||||
| export const expandHashtagTimeline      = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); | export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true }); | ||||||
| export const expandListTimeline         = id => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); | export const expandHashtagTimeline      = (hashtag, { maxId } = {}) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId }); | ||||||
|  | export const expandListTimeline         = (id, { maxId } = {}) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }); | ||||||
| 
 | 
 | ||||||
| export function expandTimelineRequest(timeline) { | export function expandTimelineRequest(timeline) { | ||||||
|   return { |   return { | ||||||
|  | @ -174,12 +100,13 @@ export function expandTimelineRequest(timeline) { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function expandTimelineSuccess(timeline, statuses, next) { | export function expandTimelineSuccess(timeline, statuses, next, partial) { | ||||||
|   return { |   return { | ||||||
|     type: TIMELINE_EXPAND_SUCCESS, |     type: TIMELINE_EXPAND_SUCCESS, | ||||||
|     timeline, |     timeline, | ||||||
|     statuses, |     statuses, | ||||||
|     next, |     next, | ||||||
|  |     partial, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -199,13 +126,6 @@ export function scrollTopTimeline(timeline, top) { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function connectTimeline(timeline) { |  | ||||||
|   return { |  | ||||||
|     type: TIMELINE_CONNECT, |  | ||||||
|     timeline, |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export function disconnectTimeline(timeline) { | export function disconnectTimeline(timeline) { | ||||||
|   return { |   return { | ||||||
|     type: TIMELINE_DISCONNECT, |     type: TIMELINE_DISCONNECT, | ||||||
|  |  | ||||||
|  | @ -6,6 +6,7 @@ export default class LoadMore extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     onClick: PropTypes.func, |     onClick: PropTypes.func, | ||||||
|  |     disabled: PropTypes.bool, | ||||||
|     visible: PropTypes.bool, |     visible: PropTypes.bool, | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -14,10 +15,10 @@ export default class LoadMore extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render() { |   render() { | ||||||
|     const { visible } = this.props; |     const { disabled, visible } = this.props; | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <button className='load-more' disabled={!visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}> |       <button className='load-more' disabled={disabled || !visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}> | ||||||
|         <FormattedMessage id='status.load_more' defaultMessage='Load more' /> |         <FormattedMessage id='status.load_more' defaultMessage='Load more' /> | ||||||
|       </button> |       </button> | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  | @ -14,10 +14,6 @@ const messages = defineMessages({ | ||||||
| 
 | 
 | ||||||
| class Item extends React.PureComponent { | class Item extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|   static contextTypes = { |  | ||||||
|     router: PropTypes.object, |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     attachment: ImmutablePropTypes.map.isRequired, |     attachment: ImmutablePropTypes.map.isRequired, | ||||||
|     standalone: PropTypes.bool, |     standalone: PropTypes.bool, | ||||||
|  | @ -53,7 +49,7 @@ class Item extends React.PureComponent { | ||||||
|   handleClick = (e) => { |   handleClick = (e) => { | ||||||
|     const { index, onClick } = this.props; |     const { index, onClick } = this.props; | ||||||
| 
 | 
 | ||||||
|     if (this.context.router && e.button === 0) { |     if (e.button === 0) { | ||||||
|       e.preventDefault(); |       e.preventDefault(); | ||||||
|       onClick(index); |       onClick(index); | ||||||
|     } |     } | ||||||
|  |  | ||||||
							
								
								
									
										84
									
								
								app/javascript/mastodon/components/modal_root.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								app/javascript/mastodon/components/modal_root.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,84 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | 
 | ||||||
|  | export default class ModalRoot extends React.PureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     children: PropTypes.node, | ||||||
|  |     onClose: PropTypes.func.isRequired, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   state = { | ||||||
|  |     revealed: !!this.props.children, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   activeElement = this.state.revealed ? document.activeElement : null; | ||||||
|  | 
 | ||||||
|  |   handleKeyUp = (e) => { | ||||||
|  |     if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) | ||||||
|  |          && !!this.props.children) { | ||||||
|  |       this.props.onClose(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentDidMount () { | ||||||
|  |     window.addEventListener('keyup', this.handleKeyUp, false); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentWillReceiveProps (nextProps) { | ||||||
|  |     if (!!nextProps.children && !this.props.children) { | ||||||
|  |       this.activeElement = document.activeElement; | ||||||
|  | 
 | ||||||
|  |       this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); | ||||||
|  |     } else if (!nextProps.children) { | ||||||
|  |       this.setState({ revealed: false }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentDidUpdate (prevProps) { | ||||||
|  |     if (!this.props.children && !!prevProps.children) { | ||||||
|  |       this.getSiblings().forEach(sibling => sibling.removeAttribute('inert')); | ||||||
|  |       this.activeElement.focus(); | ||||||
|  |       this.activeElement = null; | ||||||
|  |     } | ||||||
|  |     if (this.props.children) { | ||||||
|  |       requestAnimationFrame(() => { | ||||||
|  |         this.setState({ revealed: true }); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentWillUnmount () { | ||||||
|  |     window.removeEventListener('keyup', this.handleKeyUp); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   getSiblings = () => { | ||||||
|  |     return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   setRef = ref => { | ||||||
|  |     this.node = ref; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { children, onClose } = this.props; | ||||||
|  |     const { revealed } = this.state; | ||||||
|  |     const visible = !!children; | ||||||
|  | 
 | ||||||
|  |     if (!visible) { | ||||||
|  |       return ( | ||||||
|  |         <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} /> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}> | ||||||
|  |         <div style={{ pointerEvents: visible ? 'auto' : 'none' }}> | ||||||
|  |           <div role='presentation' className='modal-root__overlay' onClick={onClose} /> | ||||||
|  |           <div role='dialog' className='modal-root__container'>{children}</div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -17,7 +17,7 @@ export default class ScrollableList extends PureComponent { | ||||||
| 
 | 
 | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     scrollKey: PropTypes.string.isRequired, |     scrollKey: PropTypes.string.isRequired, | ||||||
|     onLoadMore: PropTypes.func.isRequired, |     onLoadMore: PropTypes.func, | ||||||
|     onScrollToTop: PropTypes.func, |     onScrollToTop: PropTypes.func, | ||||||
|     onScroll: PropTypes.func, |     onScroll: PropTypes.func, | ||||||
|     trackScroll: PropTypes.bool, |     trackScroll: PropTypes.bool, | ||||||
|  | @ -148,11 +148,11 @@ export default class ScrollableList extends PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; |     const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props; | ||||||
|     const { fullscreen } = this.state; |     const { fullscreen } = this.state; | ||||||
|     const childrenCount = React.Children.count(children); |     const childrenCount = React.Children.count(children); | ||||||
| 
 | 
 | ||||||
|     const loadMore     = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; |     const loadMore     = (hasMore && childrenCount > 0 && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; | ||||||
|     let scrollableArea = null; |     let scrollableArea = null; | ||||||
| 
 | 
 | ||||||
|     if (isLoading || childrenCount > 0 || !emptyMessage) { |     if (isLoading || childrenCount > 0 || !emptyMessage) { | ||||||
|  |  | ||||||
|  | @ -1,11 +1,31 @@ | ||||||
|  | import { debounce } from 'lodash'; | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import StatusContainer from '../containers/status_container'; | import StatusContainer from '../containers/status_container'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
|  | import LoadMore from './load_more'; | ||||||
| import ScrollableList from './scrollable_list'; | import ScrollableList from './scrollable_list'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
| 
 | 
 | ||||||
|  | class LoadGap extends ImmutablePureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     disabled: PropTypes.bool, | ||||||
|  |     maxId: PropTypes.string, | ||||||
|  |     onClick: PropTypes.func.isRequired, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleClick = () => { | ||||||
|  |     this.props.onClick(this.props.maxId); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     return <LoadMore onClick={this.handleClick} disabled={this.props.disabled} />; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export default class StatusList extends ImmutablePureComponent { | export default class StatusList extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|  | @ -38,6 +58,10 @@ export default class StatusList extends ImmutablePureComponent { | ||||||
|     this._selectChild(elementIndex); |     this._selectChild(elementIndex); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   handleLoadOlder = debounce(() => { | ||||||
|  |     this.props.onLoadMore(this.props.statusIds.last()); | ||||||
|  |   }, 300, { leading: true }) | ||||||
|  | 
 | ||||||
|   _selectChild (index) { |   _selectChild (index) { | ||||||
|     const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); |     const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); | ||||||
| 
 | 
 | ||||||
|  | @ -51,7 +75,7 @@ export default class StatusList extends ImmutablePureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { statusIds, featuredStatusIds, ...other }  = this.props; |     const { statusIds, featuredStatusIds, onLoadMore, ...other }  = this.props; | ||||||
|     const { isLoading, isPartial } = other; |     const { isLoading, isPartial } = other; | ||||||
| 
 | 
 | ||||||
|     if (isPartial) { |     if (isPartial) { | ||||||
|  | @ -70,7 +94,14 @@ export default class StatusList extends ImmutablePureComponent { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let scrollableContent = (isLoading || statusIds.size > 0) ? ( |     let scrollableContent = (isLoading || statusIds.size > 0) ? ( | ||||||
|       statusIds.map(statusId => ( |       statusIds.map((statusId, index) => statusId === null ? ( | ||||||
|  |         <LoadGap | ||||||
|  |           key={'gap:' + statusIds.get(index + 1)} | ||||||
|  |           disabled={isLoading} | ||||||
|  |           maxId={index > 0 ? statusIds.get(index - 1) : null} | ||||||
|  |           onClick={onLoadMore} | ||||||
|  |         /> | ||||||
|  |       ) : ( | ||||||
|         <StatusContainer |         <StatusContainer | ||||||
|           key={statusId} |           key={statusId} | ||||||
|           id={statusId} |           id={statusId} | ||||||
|  | @ -93,7 +124,7 @@ export default class StatusList extends ImmutablePureComponent { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <ScrollableList {...other} ref={this.setRef}> |       <ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}> | ||||||
|         {scrollableContent} |         {scrollableContent} | ||||||
|       </ScrollableList> |       </ScrollableList> | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  | @ -0,0 +1,68 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import ReactDOM from 'react-dom'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import { IntlProvider, addLocaleData } from 'react-intl'; | ||||||
|  | import { getLocale } from '../locales'; | ||||||
|  | import MediaGallery from '../components/media_gallery'; | ||||||
|  | import ModalRoot from '../components/modal_root'; | ||||||
|  | import MediaModal from '../features/ui/components/media_modal'; | ||||||
|  | import { fromJS } from 'immutable'; | ||||||
|  | 
 | ||||||
|  | const { localeData, messages } = getLocale(); | ||||||
|  | addLocaleData(localeData); | ||||||
|  | 
 | ||||||
|  | export default class MediaGalleriesContainer extends React.PureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     locale: PropTypes.string.isRequired, | ||||||
|  |     galleries: PropTypes.object.isRequired, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   state = { | ||||||
|  |     media: null, | ||||||
|  |     index: null, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleOpenMedia = (media, index) => { | ||||||
|  |     document.body.classList.add('media-gallery-standalone__body'); | ||||||
|  |     this.setState({ media, index }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   handleCloseMedia = () => { | ||||||
|  |     document.body.classList.remove('media-gallery-standalone__body'); | ||||||
|  |     this.setState({ media: null, index: null }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { locale, galleries } = this.props; | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <IntlProvider locale={locale} messages={messages}> | ||||||
|  |         <React.Fragment> | ||||||
|  |           {[].map.call(galleries, gallery => { | ||||||
|  |             const { media, ...props } = JSON.parse(gallery.getAttribute('data-props')); | ||||||
|  | 
 | ||||||
|  |             return ReactDOM.createPortal( | ||||||
|  |               <MediaGallery | ||||||
|  |                 {...props} | ||||||
|  |                 media={fromJS(media)} | ||||||
|  |                 onOpenMedia={this.handleOpenMedia} | ||||||
|  |               />, | ||||||
|  |               gallery | ||||||
|  |             ); | ||||||
|  |           })} | ||||||
|  |           <ModalRoot onClose={this.handleCloseMedia}> | ||||||
|  |             {this.state.media === null || this.state.index === null ? null : ( | ||||||
|  |               <MediaModal | ||||||
|  |                 media={this.state.media} | ||||||
|  |                 index={this.state.index} | ||||||
|  |                 onClose={this.handleCloseMedia} | ||||||
|  |               /> | ||||||
|  |             )} | ||||||
|  |           </ModalRoot> | ||||||
|  |         </React.Fragment> | ||||||
|  |       </IntlProvider> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -1,34 +0,0 @@ | ||||||
| import React from 'react'; |  | ||||||
| import PropTypes from 'prop-types'; |  | ||||||
| import { IntlProvider, addLocaleData } from 'react-intl'; |  | ||||||
| import { getLocale } from '../locales'; |  | ||||||
| import MediaGallery from '../components/media_gallery'; |  | ||||||
| import { fromJS } from 'immutable'; |  | ||||||
| 
 |  | ||||||
| const { localeData, messages } = getLocale(); |  | ||||||
| addLocaleData(localeData); |  | ||||||
| 
 |  | ||||||
| export default class MediaGalleryContainer extends React.PureComponent { |  | ||||||
| 
 |  | ||||||
|   static propTypes = { |  | ||||||
|     locale: PropTypes.string.isRequired, |  | ||||||
|     media: PropTypes.array.isRequired, |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   handleOpenMedia = () => {} |  | ||||||
| 
 |  | ||||||
|   render () { |  | ||||||
|     const { locale, media, ...props } = this.props; |  | ||||||
| 
 |  | ||||||
|     return ( |  | ||||||
|       <IntlProvider locale={locale} messages={messages}> |  | ||||||
|         <MediaGallery |  | ||||||
|           {...props} |  | ||||||
|           media={fromJS(media)} |  | ||||||
|           onOpenMedia={this.handleOpenMedia} |  | ||||||
|         /> |  | ||||||
|       </IntlProvider> |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
| } |  | ||||||
							
								
								
									
										28
									
								
								app/javascript/mastodon/db/async.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/javascript/mastodon/db/async.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | ||||||
|  | import { me } from '../initial_state'; | ||||||
|  | 
 | ||||||
|  | export default new Promise((resolve, reject) => { | ||||||
|  |   // Microsoft Edge 17 does not support getAll according to:
 | ||||||
|  |   // Catalog of standard and vendor APIs across browsers - Microsoft Edge Development
 | ||||||
|  |   // https://developer.microsoft.com/en-us/microsoft-edge/platform/catalog/?q=specName%3Aindexeddb
 | ||||||
|  |   if (!me || !('getAll' in IDBObjectStore.prototype)) { | ||||||
|  |     reject(); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const request = indexedDB.open('mastodon:' + me); | ||||||
|  | 
 | ||||||
|  |   request.onerror = reject; | ||||||
|  |   request.onsuccess = ({ target }) => resolve(target.result); | ||||||
|  | 
 | ||||||
|  |   request.onupgradeneeded = ({ target }) => { | ||||||
|  |     const accounts = target.result.createObjectStore('accounts', { autoIncrement: true }); | ||||||
|  |     const statuses = target.result.createObjectStore('statuses', { autoIncrement: true }); | ||||||
|  | 
 | ||||||
|  |     accounts.createIndex('id', 'id', { unique: true }); | ||||||
|  |     accounts.createIndex('moved', 'moved'); | ||||||
|  | 
 | ||||||
|  |     statuses.createIndex('id', 'id', { unique: true }); | ||||||
|  |     statuses.createIndex('account', 'account'); | ||||||
|  |     statuses.createIndex('reblog', 'reblog'); | ||||||
|  |   }; | ||||||
|  | }); | ||||||
							
								
								
									
										93
									
								
								app/javascript/mastodon/db/modifier.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								app/javascript/mastodon/db/modifier.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,93 @@ | ||||||
|  | import asyncDB from './async'; | ||||||
|  | 
 | ||||||
|  | const limit = 1024; | ||||||
|  | 
 | ||||||
|  | function put(name, objects, callback) { | ||||||
|  |   asyncDB.then(db => { | ||||||
|  |     const putTransaction = db.transaction(name, 'readwrite'); | ||||||
|  |     const putStore = putTransaction.objectStore(name); | ||||||
|  |     const putIndex = putStore.index('id'); | ||||||
|  | 
 | ||||||
|  |     objects.forEach(object => { | ||||||
|  |       function add() { | ||||||
|  |         putStore.add(object); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       putIndex.getKey(object.id).onsuccess = retrieval => { | ||||||
|  |         if (retrieval.target.result) { | ||||||
|  |           putStore.delete(retrieval.target.result).onsuccess = add; | ||||||
|  |         } else { | ||||||
|  |           add(); | ||||||
|  |         } | ||||||
|  |       }; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     putTransaction.oncomplete = () => { | ||||||
|  |       const readTransaction = db.transaction(name, 'readonly'); | ||||||
|  |       const readStore = readTransaction.objectStore(name); | ||||||
|  | 
 | ||||||
|  |       readStore.count().onsuccess = count => { | ||||||
|  |         const excess = count.target.result - limit; | ||||||
|  | 
 | ||||||
|  |         if (excess > 0) { | ||||||
|  |           readStore.getAll(null, excess).onsuccess = | ||||||
|  |             retrieval => callback(retrieval.target.result.map(({ id }) => id)); | ||||||
|  |         } | ||||||
|  |       }; | ||||||
|  |     }; | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function evictAccounts(ids) { | ||||||
|  |   asyncDB.then(db => { | ||||||
|  |     const transaction = db.transaction(['accounts', 'statuses'], 'readwrite'); | ||||||
|  |     const accounts = transaction.objectStore('accounts'); | ||||||
|  |     const accountsIdIndex = accounts.index('id'); | ||||||
|  |     const accountsMovedIndex = accounts.index('moved'); | ||||||
|  |     const statuses = transaction.objectStore('statuses'); | ||||||
|  |     const statusesIndex = statuses.index('account'); | ||||||
|  | 
 | ||||||
|  |     function evict(toEvict) { | ||||||
|  |       toEvict.forEach(id => { | ||||||
|  |         accountsMovedIndex.getAllKeys(id).onsuccess = | ||||||
|  |           ({ target }) => evict(target.result); | ||||||
|  | 
 | ||||||
|  |         statusesIndex.getAll(id).onsuccess = | ||||||
|  |           ({ target }) => evictStatuses(target.result.map(({ id }) => id)); | ||||||
|  | 
 | ||||||
|  |         accountsIdIndex.getKey(id).onsuccess = | ||||||
|  |           ({ target }) => target.result && accounts.delete(target.result); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     evict(ids); | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function evictStatus(id) { | ||||||
|  |   return evictStatuses([id]); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function evictStatuses(ids) { | ||||||
|  |   asyncDB.then(db => { | ||||||
|  |     const store = db.transaction('statuses', 'readwrite').objectStore('statuses'); | ||||||
|  |     const idIndex = store.index('id'); | ||||||
|  |     const reblogIndex = store.index('reblog'); | ||||||
|  | 
 | ||||||
|  |     ids.forEach(id => { | ||||||
|  |       reblogIndex.getAllKeys(id).onsuccess = | ||||||
|  |         ({ target }) => target.result.forEach(reblogKey => store.delete(reblogKey)); | ||||||
|  | 
 | ||||||
|  |       idIndex.getKey(id).onsuccess = | ||||||
|  |         ({ target }) => target.result && store.delete(target.result); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function putAccounts(records) { | ||||||
|  |   put('accounts', records, evictAccounts); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function putStatuses(records) { | ||||||
|  |   put('statuses', records, evictStatuses); | ||||||
|  | } | ||||||
|  | @ -3,7 +3,7 @@ import { connect } from 'react-redux'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import { fetchAccount } from '../../actions/accounts'; | import { fetchAccount } from '../../actions/accounts'; | ||||||
| import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from '../../actions/timelines'; | import { expandAccountMediaTimeline } from '../../actions/timelines'; | ||||||
| import LoadingIndicator from '../../components/loading_indicator'; | import LoadingIndicator from '../../components/loading_indicator'; | ||||||
| import Column from '../ui/components/column'; | import Column from '../ui/components/column'; | ||||||
| import ColumnBackButton from '../../components/column_back_button'; | import ColumnBackButton from '../../components/column_back_button'; | ||||||
|  | @ -17,9 +17,31 @@ import LoadMore from '../../components/load_more'; | ||||||
| const mapStateToProps = (state, props) => ({ | const mapStateToProps = (state, props) => ({ | ||||||
|   medias: getAccountGallery(state, props.params.accountId), |   medias: getAccountGallery(state, props.params.accountId), | ||||||
|   isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), |   isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), | ||||||
|   hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']), |   hasMore:   state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
|  | class LoadMoreMedia extends ImmutablePureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     maxId: PropTypes.string, | ||||||
|  |     onLoadMore: PropTypes.func.isRequired, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleLoadMore = () => { | ||||||
|  |     this.props.onLoadMore(this.props.maxId); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     return ( | ||||||
|  |       <LoadMore | ||||||
|  |         disabled={this.props.disabled} | ||||||
|  |         onLoadMore={this.handleLoadMore} | ||||||
|  |       /> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
| @connect(mapStateToProps) | @connect(mapStateToProps) | ||||||
| export default class AccountGallery extends ImmutablePureComponent { | export default class AccountGallery extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|  | @ -33,19 +55,19 @@ export default class AccountGallery extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|     this.props.dispatch(fetchAccount(this.props.params.accountId)); |     this.props.dispatch(fetchAccount(this.props.params.accountId)); | ||||||
|     this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); |     this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentWillReceiveProps (nextProps) { |   componentWillReceiveProps (nextProps) { | ||||||
|     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { |     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { | ||||||
|       this.props.dispatch(fetchAccount(nextProps.params.accountId)); |       this.props.dispatch(fetchAccount(nextProps.params.accountId)); | ||||||
|       this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); |       this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleScrollToBottom = () => { |   handleScrollToBottom = () => { | ||||||
|     if (this.props.hasMore) { |     if (this.props.hasMore) { | ||||||
|       this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); |       this.handleLoadMore(this.props.medias.last().get('id')); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -58,7 +80,11 @@ export default class AccountGallery extends ImmutablePureComponent { | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadMore = (e) => { |   handleLoadMore = maxId => { | ||||||
|  |     this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId })); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleLoadOlder = (e) => { | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|     this.handleScrollToBottom(); |     this.handleScrollToBottom(); | ||||||
|   } |   } | ||||||
|  | @ -66,7 +92,7 @@ export default class AccountGallery extends ImmutablePureComponent { | ||||||
|   render () { |   render () { | ||||||
|     const { medias, isLoading, hasMore } = this.props; |     const { medias, isLoading, hasMore } = this.props; | ||||||
| 
 | 
 | ||||||
|     let loadMore = null; |     let loadOlder = null; | ||||||
| 
 | 
 | ||||||
|     if (!medias && isLoading) { |     if (!medias && isLoading) { | ||||||
|       return ( |       return ( | ||||||
|  | @ -77,7 +103,7 @@ export default class AccountGallery extends ImmutablePureComponent { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (!isLoading && medias.size > 0 && hasMore) { |     if (!isLoading && medias.size > 0 && hasMore) { | ||||||
|       loadMore = <LoadMore onClick={this.handleLoadMore} />; |       loadOlder = <LoadMore onClick={this.handleLoadOlder} />; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|  | @ -89,13 +115,18 @@ export default class AccountGallery extends ImmutablePureComponent { | ||||||
|             <HeaderContainer accountId={this.props.params.accountId} /> |             <HeaderContainer accountId={this.props.params.accountId} /> | ||||||
| 
 | 
 | ||||||
|             <div className='account-gallery__container'> |             <div className='account-gallery__container'> | ||||||
|               {medias.map(media => ( |               {medias.map((media, index) => media === null ? ( | ||||||
|  |                 <LoadMoreMedia | ||||||
|  |                   key={'more:' + medias.getIn(index + 1, 'id')} | ||||||
|  |                   maxId={index > 0 ? medias.getIn(index - 1, 'id') : null} | ||||||
|  |                 /> | ||||||
|  |               ) : ( | ||||||
|                 <MediaItem |                 <MediaItem | ||||||
|                   key={media.get('id')} |                   key={media.get('id')} | ||||||
|                   media={media} |                   media={media} | ||||||
|                 /> |                 /> | ||||||
|               ))} |               ))} | ||||||
|               {loadMore} |               {loadOlder} | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|         </ScrollContainer> |         </ScrollContainer> | ||||||
|  |  | ||||||
|  | @ -99,7 +99,7 @@ export default class Header extends ImmutablePureComponent { | ||||||
|         {!hideTabs && ( |         {!hideTabs && ( | ||||||
|           <div className='account__section-headline'> |           <div className='account__section-headline'> | ||||||
|             <NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink> |             <NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink> | ||||||
|             <NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink> |             <NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots and replies' /></NavLink> | ||||||
|             <NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink> |             <NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink> | ||||||
|           </div> |           </div> | ||||||
|         )} |         )} | ||||||
|  |  | ||||||
|  | @ -3,7 +3,7 @@ import { connect } from 'react-redux'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import { fetchAccount } from '../../actions/accounts'; | import { fetchAccount } from '../../actions/accounts'; | ||||||
| import { refreshAccountTimeline, refreshAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines'; | import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines'; | ||||||
| import StatusList from '../../components/status_list'; | import StatusList from '../../components/status_list'; | ||||||
| import LoadingIndicator from '../../components/loading_indicator'; | import LoadingIndicator from '../../components/loading_indicator'; | ||||||
| import Column from '../ui/components/column'; | import Column from '../ui/components/column'; | ||||||
|  | @ -19,7 +19,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false }) | ||||||
|     statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()), |     statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()), | ||||||
|     featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()), |     featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()), | ||||||
|     isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), |     isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), | ||||||
|     hasMore: !!state.getIn(['timelines', `account:${path}`, 'next']), |     hasMore:   state.getIn(['timelines', `account:${path}`, 'hasMore']), | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -41,25 +41,23 @@ export default class AccountTimeline extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|     this.props.dispatch(fetchAccount(accountId)); |     this.props.dispatch(fetchAccount(accountId)); | ||||||
|     if (!withReplies) { |     if (!withReplies) { | ||||||
|       this.props.dispatch(refreshAccountFeaturedTimeline(accountId)); |       this.props.dispatch(expandAccountFeaturedTimeline(accountId)); | ||||||
|     } |     } | ||||||
|     this.props.dispatch(refreshAccountTimeline(accountId, withReplies)); |     this.props.dispatch(expandAccountTimeline(accountId, { withReplies })); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentWillReceiveProps (nextProps) { |   componentWillReceiveProps (nextProps) { | ||||||
|     if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) { |     if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) { | ||||||
|       this.props.dispatch(fetchAccount(nextProps.params.accountId)); |       this.props.dispatch(fetchAccount(nextProps.params.accountId)); | ||||||
|       if (!nextProps.withReplies) { |       if (!nextProps.withReplies) { | ||||||
|         this.props.dispatch(refreshAccountFeaturedTimeline(nextProps.params.accountId)); |         this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId)); | ||||||
|       } |       } | ||||||
|       this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId, nextProps.params.withReplies)); |       this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies })); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadMore = () => { |   handleLoadMore = maxId => { | ||||||
|     if (!this.props.isLoading && this.props.hasMore) { |     this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies })); | ||||||
|       this.props.dispatch(expandAccountTimeline(this.props.params.accountId, this.props.withReplies)); |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|  |  | ||||||
|  | @ -4,10 +4,7 @@ import PropTypes from 'prop-types'; | ||||||
| import StatusListContainer from '../ui/containers/status_list_container'; | import StatusListContainer from '../ui/containers/status_list_container'; | ||||||
| import Column from '../../components/column'; | import Column from '../../components/column'; | ||||||
| import ColumnHeader from '../../components/column_header'; | import ColumnHeader from '../../components/column_header'; | ||||||
| import { | import { expandCommunityTimeline } from '../../actions/timelines'; | ||||||
|   refreshCommunityTimeline, |  | ||||||
|   expandCommunityTimeline, |  | ||||||
| } from '../../actions/timelines'; |  | ||||||
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import ColumnSettingsContainer from './containers/column_settings_container'; | import ColumnSettingsContainer from './containers/column_settings_container'; | ||||||
|  | @ -55,7 +52,7 @@ export default class CommunityTimeline extends React.PureComponent { | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|     const { dispatch } = this.props; |     const { dispatch } = this.props; | ||||||
| 
 | 
 | ||||||
|     dispatch(refreshCommunityTimeline()); |     dispatch(expandCommunityTimeline()); | ||||||
|     this.disconnect = dispatch(connectCommunityStream()); |     this.disconnect = dispatch(connectCommunityStream()); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -70,8 +67,8 @@ export default class CommunityTimeline extends React.PureComponent { | ||||||
|     this.column = c; |     this.column = c; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadMore = () => { |   handleLoadMore = maxId => { | ||||||
|     this.props.dispatch(expandCommunityTimeline()); |     this.props.dispatch(expandCommunityTimeline({ maxId })); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|  | @ -97,7 +94,7 @@ export default class CommunityTimeline extends React.PureComponent { | ||||||
|           trackScroll={!pinned} |           trackScroll={!pinned} | ||||||
|           scrollKey={`community_timeline-${columnId}`} |           scrollKey={`community_timeline-${columnId}`} | ||||||
|           timelineId='community' |           timelineId='community' | ||||||
|           loadMore={this.handleLoadMore} |           onLoadMore={this.handleLoadMore} | ||||||
|           emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} |           emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} | ||||||
|         /> |         /> | ||||||
|       </Column> |       </Column> | ||||||
|  |  | ||||||
|  | @ -4,10 +4,7 @@ import PropTypes from 'prop-types'; | ||||||
| import StatusListContainer from '../ui/containers/status_list_container'; | import StatusListContainer from '../ui/containers/status_list_container'; | ||||||
| import Column from '../../components/column'; | import Column from '../../components/column'; | ||||||
| import ColumnHeader from '../../components/column_header'; | import ColumnHeader from '../../components/column_header'; | ||||||
| import { | import { expandHashtagTimeline } from '../../actions/timelines'; | ||||||
|   refreshHashtagTimeline, |  | ||||||
|   expandHashtagTimeline, |  | ||||||
| } from '../../actions/timelines'; |  | ||||||
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
| import { connectHashtagStream } from '../../actions/streaming'; | import { connectHashtagStream } from '../../actions/streaming'; | ||||||
|  | @ -61,13 +58,13 @@ export default class HashtagTimeline extends React.PureComponent { | ||||||
|     const { dispatch } = this.props; |     const { dispatch } = this.props; | ||||||
|     const { id } = this.props.params; |     const { id } = this.props.params; | ||||||
| 
 | 
 | ||||||
|     dispatch(refreshHashtagTimeline(id)); |     dispatch(expandHashtagTimeline(id)); | ||||||
|     this._subscribe(dispatch, id); |     this._subscribe(dispatch, id); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentWillReceiveProps (nextProps) { |   componentWillReceiveProps (nextProps) { | ||||||
|     if (nextProps.params.id !== this.props.params.id) { |     if (nextProps.params.id !== this.props.params.id) { | ||||||
|       this.props.dispatch(refreshHashtagTimeline(nextProps.params.id)); |       this.props.dispatch(expandHashtagTimeline(nextProps.params.id)); | ||||||
|       this._unsubscribe(); |       this._unsubscribe(); | ||||||
|       this._subscribe(this.props.dispatch, nextProps.params.id); |       this._subscribe(this.props.dispatch, nextProps.params.id); | ||||||
|     } |     } | ||||||
|  | @ -81,8 +78,8 @@ export default class HashtagTimeline extends React.PureComponent { | ||||||
|     this.column = c; |     this.column = c; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadMore = () => { |   handleLoadMore = maxId => { | ||||||
|     this.props.dispatch(expandHashtagTimeline(this.props.params.id)); |     this.props.dispatch(expandHashtagTimeline(this.props.params.id, { maxId })); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|  | @ -108,7 +105,7 @@ export default class HashtagTimeline extends React.PureComponent { | ||||||
|           trackScroll={!pinned} |           trackScroll={!pinned} | ||||||
|           scrollKey={`hashtag_timeline-${columnId}`} |           scrollKey={`hashtag_timeline-${columnId}`} | ||||||
|           timelineId={`hashtag:${id}`} |           timelineId={`hashtag:${id}`} | ||||||
|           loadMore={this.handleLoadMore} |           onLoadMore={this.handleLoadMore} | ||||||
|           emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} |           emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} | ||||||
|         /> |         /> | ||||||
|       </Column> |       </Column> | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import { expandHomeTimeline, refreshHomeTimeline } from '../../actions/timelines'; | import { expandHomeTimeline } from '../../actions/timelines'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import StatusListContainer from '../ui/containers/status_list_container'; | import StatusListContainer from '../ui/containers/status_list_container'; | ||||||
| import Column from '../../components/column'; | import Column from '../../components/column'; | ||||||
|  | @ -16,7 +16,7 @@ const messages = defineMessages({ | ||||||
| 
 | 
 | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = state => ({ | ||||||
|   hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, |   hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, | ||||||
|   isPartial: state.getIn(['timelines', 'home', 'isPartial'], false), |   isPartial: state.getIn(['timelines', 'home', 'items', 0], null) === null, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| @connect(mapStateToProps) | @connect(mapStateToProps) | ||||||
|  | @ -55,8 +55,8 @@ export default class HomeTimeline extends React.PureComponent { | ||||||
|     this.column = c; |     this.column = c; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadMore = () => { |   handleLoadMore = maxId => { | ||||||
|     this.props.dispatch(expandHomeTimeline()); |     this.props.dispatch(expandHomeTimeline({ maxId })); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|  | @ -78,7 +78,7 @@ export default class HomeTimeline extends React.PureComponent { | ||||||
|       return; |       return; | ||||||
|     } else if (!wasPartial && isPartial) { |     } else if (!wasPartial && isPartial) { | ||||||
|       this.polling = setInterval(() => { |       this.polling = setInterval(() => { | ||||||
|         dispatch(refreshHomeTimeline()); |         dispatch(expandHomeTimeline()); | ||||||
|       }, 3000); |       }, 3000); | ||||||
|     } else if (wasPartial && !isPartial) { |     } else if (wasPartial && !isPartial) { | ||||||
|       this._stopPolling(); |       this._stopPolling(); | ||||||
|  | @ -114,7 +114,7 @@ export default class HomeTimeline extends React.PureComponent { | ||||||
|         <StatusListContainer |         <StatusListContainer | ||||||
|           trackScroll={!pinned} |           trackScroll={!pinned} | ||||||
|           scrollKey={`home_timeline-${columnId}`} |           scrollKey={`home_timeline-${columnId}`} | ||||||
|           loadMore={this.handleLoadMore} |           onLoadMore={this.handleLoadMore} | ||||||
|           timelineId='home' |           timelineId='home' | ||||||
|           emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Visit {public} or use search to get started and meet other users.' values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} |           emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Visit {public} or use search to get started and meet other users.' values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|  | @ -8,7 +8,7 @@ import ColumnHeader from '../../components/column_header'; | ||||||
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | ||||||
| import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; | import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; | ||||||
| import { connectListStream } from '../../actions/streaming'; | import { connectListStream } from '../../actions/streaming'; | ||||||
| import { refreshListTimeline, expandListTimeline } from '../../actions/timelines'; | import { expandListTimeline } from '../../actions/timelines'; | ||||||
| import { fetchList, deleteList } from '../../actions/lists'; | import { fetchList, deleteList } from '../../actions/lists'; | ||||||
| import { openModal } from '../../actions/modal'; | import { openModal } from '../../actions/modal'; | ||||||
| import MissingIndicator from '../../components/missing_indicator'; | import MissingIndicator from '../../components/missing_indicator'; | ||||||
|  | @ -67,7 +67,7 @@ export default class ListTimeline extends React.PureComponent { | ||||||
|     const { id } = this.props.params; |     const { id } = this.props.params; | ||||||
| 
 | 
 | ||||||
|     dispatch(fetchList(id)); |     dispatch(fetchList(id)); | ||||||
|     dispatch(refreshListTimeline(id)); |     dispatch(expandListTimeline(id)); | ||||||
| 
 | 
 | ||||||
|     this.disconnect = dispatch(connectListStream(id)); |     this.disconnect = dispatch(connectListStream(id)); | ||||||
|   } |   } | ||||||
|  | @ -83,9 +83,9 @@ export default class ListTimeline extends React.PureComponent { | ||||||
|     this.column = c; |     this.column = c; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadMore = () => { |   handleLoadMore = maxId => { | ||||||
|     const { id } = this.props.params; |     const { id } = this.props.params; | ||||||
|     this.props.dispatch(expandListTimeline(id)); |     this.props.dispatch(expandListTimeline(id, { maxId })); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleEditClick = () => { |   handleEditClick = () => { | ||||||
|  | @ -164,7 +164,7 @@ export default class ListTimeline extends React.PureComponent { | ||||||
|           trackScroll={!pinned} |           trackScroll={!pinned} | ||||||
|           scrollKey={`list_timeline-${columnId}`} |           scrollKey={`list_timeline-${columnId}`} | ||||||
|           timelineId={`list:${id}`} |           timelineId={`list:${id}`} | ||||||
|           loadMore={this.handleLoadMore} |           onLoadMore={this.handleLoadMore} | ||||||
|           emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet. When members of this list post new statuses, they will appear here.' />} |           emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet. When members of this list post new statuses, they will appear here.' />} | ||||||
|         /> |         /> | ||||||
|       </Column> |       </Column> | ||||||
|  |  | ||||||
|  | @ -13,6 +13,7 @@ import { createSelector } from 'reselect'; | ||||||
| import { List as ImmutableList } from 'immutable'; | import { List as ImmutableList } from 'immutable'; | ||||||
| import { debounce } from 'lodash'; | import { debounce } from 'lodash'; | ||||||
| import ScrollableList from '../../components/scrollable_list'; | import ScrollableList from '../../components/scrollable_list'; | ||||||
|  | import LoadMore from '../../components/load_more'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   title: { id: 'column.notifications', defaultMessage: 'Notifications' }, |   title: { id: 'column.notifications', defaultMessage: 'Notifications' }, | ||||||
|  | @ -21,13 +22,31 @@ const messages = defineMessages({ | ||||||
| const getNotifications = createSelector([ | const getNotifications = createSelector([ | ||||||
|   state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), |   state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), | ||||||
|   state => state.getIn(['notifications', 'items']), |   state => state.getIn(['notifications', 'items']), | ||||||
| ], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type')))); | ], (excludedTypes, notifications) => notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')))); | ||||||
|  | 
 | ||||||
|  | class LoadGap extends React.PureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     disabled: PropTypes.bool, | ||||||
|  |     maxId: PropTypes.string, | ||||||
|  |     onClick: PropTypes.func.isRequired, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleClick = () => { | ||||||
|  |     this.props.onClick(this.props.maxId); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     return <LoadMore onClick={this.handleClick} disabled={this.props.disabled} />; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = state => ({ | ||||||
|   notifications: getNotifications(state), |   notifications: getNotifications(state), | ||||||
|   isLoading: state.getIn(['notifications', 'isLoading'], true), |   isLoading: state.getIn(['notifications', 'isLoading'], true), | ||||||
|   isUnread: state.getIn(['notifications', 'unread']) > 0, |   isUnread: state.getIn(['notifications', 'unread']) > 0, | ||||||
|   hasMore: !!state.getIn(['notifications', 'next']), |   hasMore: state.getIn(['notifications', 'hasMore']), | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| @connect(mapStateToProps) | @connect(mapStateToProps) | ||||||
|  | @ -51,14 +70,19 @@ export default class Notifications extends React.PureComponent { | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   componentWillUnmount () { |   componentWillUnmount () { | ||||||
|     this.handleLoadMore.cancel(); |     this.handleLoadOlder.cancel(); | ||||||
|     this.handleScrollToTop.cancel(); |     this.handleScrollToTop.cancel(); | ||||||
|     this.handleScroll.cancel(); |     this.handleScroll.cancel(); | ||||||
|     this.props.dispatch(scrollTopNotifications(false)); |     this.props.dispatch(scrollTopNotifications(false)); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadMore = debounce(() => { |   handleLoadGap = (maxId) => { | ||||||
|     this.props.dispatch(expandNotifications()); |     this.props.dispatch(expandNotifications({ maxId })); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleLoadOlder = debounce(() => { | ||||||
|  |     const last = this.props.notifications.last(); | ||||||
|  |     this.props.dispatch(expandNotifications({ maxId: last && last.get('id') })); | ||||||
|   }, 300, { leading: true }); |   }, 300, { leading: true }); | ||||||
| 
 | 
 | ||||||
|   handleScrollToTop = debounce(() => { |   handleScrollToTop = debounce(() => { | ||||||
|  | @ -93,12 +117,12 @@ export default class Notifications extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleMoveUp = id => { |   handleMoveUp = id => { | ||||||
|     const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1; |     const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1; | ||||||
|     this._selectChild(elementIndex); |     this._selectChild(elementIndex); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleMoveDown = id => { |   handleMoveDown = id => { | ||||||
|     const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1; |     const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1; | ||||||
|     this._selectChild(elementIndex); |     this._selectChild(elementIndex); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -120,7 +144,14 @@ export default class Notifications extends React.PureComponent { | ||||||
|     if (isLoading && this.scrollableContent) { |     if (isLoading && this.scrollableContent) { | ||||||
|       scrollableContent = this.scrollableContent; |       scrollableContent = this.scrollableContent; | ||||||
|     } else if (notifications.size > 0 || hasMore) { |     } else if (notifications.size > 0 || hasMore) { | ||||||
|       scrollableContent = notifications.map((item) => ( |       scrollableContent = notifications.map((item, index) => item === null ? ( | ||||||
|  |         <LoadGap | ||||||
|  |           key={'gap:' + notifications.getIn([index + 1, 'id'])} | ||||||
|  |           disabled={isLoading} | ||||||
|  |           maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null} | ||||||
|  |           onClick={this.handleLoadGap} | ||||||
|  |         /> | ||||||
|  |       ) : ( | ||||||
|         <NotificationContainer |         <NotificationContainer | ||||||
|           key={item.get('id')} |           key={item.get('id')} | ||||||
|           notification={item} |           notification={item} | ||||||
|  | @ -142,7 +173,7 @@ export default class Notifications extends React.PureComponent { | ||||||
|         isLoading={isLoading} |         isLoading={isLoading} | ||||||
|         hasMore={hasMore} |         hasMore={hasMore} | ||||||
|         emptyMessage={emptyMessage} |         emptyMessage={emptyMessage} | ||||||
|         onLoadMore={this.handleLoadMore} |         onLoadMore={this.handleLoadOlder} | ||||||
|         onScrollToTop={this.handleScrollToTop} |         onScrollToTop={this.handleScrollToTop} | ||||||
|         onScroll={this.handleScroll} |         onScroll={this.handleScroll} | ||||||
|         shouldUpdateScroll={shouldUpdateScroll} |         shouldUpdateScroll={shouldUpdateScroll} | ||||||
|  |  | ||||||
|  | @ -4,10 +4,7 @@ import PropTypes from 'prop-types'; | ||||||
| import StatusListContainer from '../ui/containers/status_list_container'; | import StatusListContainer from '../ui/containers/status_list_container'; | ||||||
| import Column from '../../components/column'; | import Column from '../../components/column'; | ||||||
| import ColumnHeader from '../../components/column_header'; | import ColumnHeader from '../../components/column_header'; | ||||||
| import { | import { expandPublicTimeline } from '../../actions/timelines'; | ||||||
|   refreshPublicTimeline, |  | ||||||
|   expandPublicTimeline, |  | ||||||
| } from '../../actions/timelines'; |  | ||||||
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import ColumnSettingsContainer from './containers/column_settings_container'; | import ColumnSettingsContainer from './containers/column_settings_container'; | ||||||
|  | @ -55,7 +52,7 @@ export default class PublicTimeline extends React.PureComponent { | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|     const { dispatch } = this.props; |     const { dispatch } = this.props; | ||||||
| 
 | 
 | ||||||
|     dispatch(refreshPublicTimeline()); |     dispatch(expandPublicTimeline()); | ||||||
|     this.disconnect = dispatch(connectPublicStream()); |     this.disconnect = dispatch(connectPublicStream()); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -70,8 +67,8 @@ export default class PublicTimeline extends React.PureComponent { | ||||||
|     this.column = c; |     this.column = c; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadMore = () => { |   handleLoadMore = maxId => { | ||||||
|     this.props.dispatch(expandPublicTimeline()); |     this.props.dispatch(expandPublicTimeline({ maxId })); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|  | @ -95,7 +92,7 @@ export default class PublicTimeline extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|         <StatusListContainer |         <StatusListContainer | ||||||
|           timelineId='public' |           timelineId='public' | ||||||
|           loadMore={this.handleLoadMore} |           onLoadMore={this.handleLoadMore} | ||||||
|           trackScroll={!pinned} |           trackScroll={!pinned} | ||||||
|           scrollKey={`public_timeline-${columnId}`} |           scrollKey={`public_timeline-${columnId}`} | ||||||
|           emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} |           emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} | ||||||
|  |  | ||||||
|  | @ -2,10 +2,7 @@ import React from 'react'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import StatusListContainer from '../../ui/containers/status_list_container'; | import StatusListContainer from '../../ui/containers/status_list_container'; | ||||||
| import { | import { expandCommunityTimeline } from '../../../actions/timelines'; | ||||||
|   refreshCommunityTimeline, |  | ||||||
|   expandCommunityTimeline, |  | ||||||
| } from '../../../actions/timelines'; |  | ||||||
| import Column from '../../../components/column'; | import Column from '../../../components/column'; | ||||||
| import ColumnHeader from '../../../components/column_header'; | import ColumnHeader from '../../../components/column_header'; | ||||||
| import { defineMessages, injectIntl } from 'react-intl'; | import { defineMessages, injectIntl } from 'react-intl'; | ||||||
|  | @ -35,7 +32,7 @@ export default class CommunityTimeline extends React.PureComponent { | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|     const { dispatch } = this.props; |     const { dispatch } = this.props; | ||||||
| 
 | 
 | ||||||
|     dispatch(refreshCommunityTimeline()); |     dispatch(expandCommunityTimeline()); | ||||||
|     this.disconnect = dispatch(connectCommunityStream()); |     this.disconnect = dispatch(connectCommunityStream()); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -46,8 +43,8 @@ export default class CommunityTimeline extends React.PureComponent { | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadMore = () => { |   handleLoadMore = maxId => { | ||||||
|     this.props.dispatch(expandCommunityTimeline()); |     this.props.dispatch(expandCommunityTimeline({ maxId })); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|  | @ -63,7 +60,7 @@ export default class CommunityTimeline extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|         <StatusListContainer |         <StatusListContainer | ||||||
|           timelineId='community' |           timelineId='community' | ||||||
|           loadMore={this.handleLoadMore} |           onLoadMore={this.handleLoadMore} | ||||||
|           scrollKey='standalone_public_timeline' |           scrollKey='standalone_public_timeline' | ||||||
|           trackScroll={false} |           trackScroll={false} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|  | @ -2,10 +2,7 @@ import React from 'react'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import StatusListContainer from '../../ui/containers/status_list_container'; | import StatusListContainer from '../../ui/containers/status_list_container'; | ||||||
| import { | import { expandHashtagTimeline } from '../../../actions/timelines'; | ||||||
|   refreshHashtagTimeline, |  | ||||||
|   expandHashtagTimeline, |  | ||||||
| } from '../../../actions/timelines'; |  | ||||||
| import Column from '../../../components/column'; | import Column from '../../../components/column'; | ||||||
| import ColumnHeader from '../../../components/column_header'; | import ColumnHeader from '../../../components/column_header'; | ||||||
| import { connectHashtagStream } from '../../../actions/streaming'; | import { connectHashtagStream } from '../../../actions/streaming'; | ||||||
|  | @ -29,7 +26,7 @@ export default class HashtagTimeline extends React.PureComponent { | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|     const { dispatch, hashtag } = this.props; |     const { dispatch, hashtag } = this.props; | ||||||
| 
 | 
 | ||||||
|     dispatch(refreshHashtagTimeline(hashtag)); |     dispatch(expandHashtagTimeline(hashtag)); | ||||||
|     this.disconnect = dispatch(connectHashtagStream(hashtag)); |     this.disconnect = dispatch(connectHashtagStream(hashtag)); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -40,8 +37,8 @@ export default class HashtagTimeline extends React.PureComponent { | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadMore = () => { |   handleLoadMore = maxId => { | ||||||
|     this.props.dispatch(expandHashtagTimeline(this.props.hashtag)); |     this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId })); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|  | @ -59,7 +56,7 @@ export default class HashtagTimeline extends React.PureComponent { | ||||||
|           trackScroll={false} |           trackScroll={false} | ||||||
|           scrollKey='standalone_hashtag_timeline' |           scrollKey='standalone_hashtag_timeline' | ||||||
|           timelineId={`hashtag:${hashtag}`} |           timelineId={`hashtag:${hashtag}`} | ||||||
|           loadMore={this.handleLoadMore} |           onLoadMore={this.handleLoadMore} | ||||||
|         /> |         /> | ||||||
|       </Column> |       </Column> | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  | @ -2,10 +2,7 @@ import React from 'react'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import StatusListContainer from '../../ui/containers/status_list_container'; | import StatusListContainer from '../../ui/containers/status_list_container'; | ||||||
| import { | import { expandPublicTimeline } from '../../../actions/timelines'; | ||||||
|   refreshPublicTimeline, |  | ||||||
|   expandPublicTimeline, |  | ||||||
| } from '../../../actions/timelines'; |  | ||||||
| import Column from '../../../components/column'; | import Column from '../../../components/column'; | ||||||
| import ColumnHeader from '../../../components/column_header'; | import ColumnHeader from '../../../components/column_header'; | ||||||
| import { defineMessages, injectIntl } from 'react-intl'; | import { defineMessages, injectIntl } from 'react-intl'; | ||||||
|  | @ -35,7 +32,7 @@ export default class PublicTimeline extends React.PureComponent { | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|     const { dispatch } = this.props; |     const { dispatch } = this.props; | ||||||
| 
 | 
 | ||||||
|     dispatch(refreshPublicTimeline()); |     dispatch(expandPublicTimeline()); | ||||||
|     this.disconnect = dispatch(connectPublicStream()); |     this.disconnect = dispatch(connectPublicStream()); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -46,8 +43,8 @@ export default class PublicTimeline extends React.PureComponent { | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadMore = () => { |   handleLoadMore = maxId => { | ||||||
|     this.props.dispatch(expandPublicTimeline()); |     this.props.dispatch(expandPublicTimeline({ maxId })); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|  | @ -63,7 +60,7 @@ export default class PublicTimeline extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|         <StatusListContainer |         <StatusListContainer | ||||||
|           timelineId='public' |           timelineId='public' | ||||||
|           loadMore={this.handleLoadMore} |           onLoadMore={this.handleLoadMore} | ||||||
|           scrollKey='standalone_public_timeline' |           scrollKey='standalone_public_timeline' | ||||||
|           trackScroll={false} |           trackScroll={false} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|  | @ -1,5 +1,6 @@ | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
|  | import Base from '../../../components/modal_root'; | ||||||
| import BundleContainer from '../containers/bundle_container'; | import BundleContainer from '../containers/bundle_container'; | ||||||
| import BundleModalError from './bundle_modal_error'; | import BundleModalError from './bundle_modal_error'; | ||||||
| import ModalLoading from './modal_loading'; | import ModalLoading from './modal_loading'; | ||||||
|  | @ -39,56 +40,6 @@ export default class ModalRoot extends React.PureComponent { | ||||||
|     onClose: PropTypes.func.isRequired, |     onClose: PropTypes.func.isRequired, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   state = { |  | ||||||
|     revealed: false, |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   handleKeyUp = (e) => { |  | ||||||
|     if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) |  | ||||||
|          && !!this.props.type) { |  | ||||||
|       this.props.onClose(); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   componentDidMount () { |  | ||||||
|     window.addEventListener('keyup', this.handleKeyUp, false); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   componentWillReceiveProps (nextProps) { |  | ||||||
|     if (!!nextProps.type && !this.props.type) { |  | ||||||
|       this.activeElement = document.activeElement; |  | ||||||
| 
 |  | ||||||
|       this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); |  | ||||||
|     } else if (!nextProps.type) { |  | ||||||
|       this.setState({ revealed: false }); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   componentDidUpdate (prevProps) { |  | ||||||
|     if (!this.props.type && !!prevProps.type) { |  | ||||||
|       this.getSiblings().forEach(sibling => sibling.removeAttribute('inert')); |  | ||||||
|       this.activeElement.focus(); |  | ||||||
|       this.activeElement = null; |  | ||||||
|     } |  | ||||||
|     if (this.props.type) { |  | ||||||
|       requestAnimationFrame(() => { |  | ||||||
|         this.setState({ revealed: true }); |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   componentWillUnmount () { |  | ||||||
|     window.removeEventListener('keyup', this.handleKeyUp); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   getSiblings = () => { |  | ||||||
|     return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   setRef = ref => { |  | ||||||
|     this.node = ref; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   renderLoading = modalId => () => { |   renderLoading = modalId => () => { | ||||||
|     return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; |     return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; | ||||||
|   } |   } | ||||||
|  | @ -101,28 +52,16 @@ export default class ModalRoot extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { type, props, onClose } = this.props; |     const { type, props, onClose } = this.props; | ||||||
|     const { revealed } = this.state; |  | ||||||
|     const visible = !!type; |     const visible = !!type; | ||||||
| 
 | 
 | ||||||
|     if (!visible) { |  | ||||||
|       return ( |  | ||||||
|         <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} /> |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return ( |     return ( | ||||||
|       <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}> |       <Base onClose={onClose}> | ||||||
|         <div style={{ pointerEvents: visible ? 'auto' : 'none' }}> |         {visible && ( | ||||||
|           <div role='presentation' className='modal-root__overlay' onClick={onClose} /> |           <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}> | ||||||
|           <div role='dialog' className='modal-root__container'> |             {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />} | ||||||
|             {visible && ( |           </BundleContainer> | ||||||
|               <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}> |         )} | ||||||
|                 {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />} |       </Base> | ||||||
|               </BundleContainer> |  | ||||||
|             )} |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import { changeReportComment, changeReportForward, submitReport } from '../../../actions/reports'; | import { changeReportComment, changeReportForward, submitReport } from '../../../actions/reports'; | ||||||
| import { refreshAccountTimeline } from '../../../actions/timelines'; | import { expandAccountTimeline } from '../../../actions/timelines'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import { makeGetAccount } from '../../../selectors'; | import { makeGetAccount } from '../../../selectors'; | ||||||
|  | @ -64,12 +64,12 @@ export default class ReportModal extends ImmutablePureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|     this.props.dispatch(refreshAccountTimeline(this.props.account.get('id'))); |     this.props.dispatch(expandAccountTimeline(this.props.account.get('id'))); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentWillReceiveProps (nextProps) { |   componentWillReceiveProps (nextProps) { | ||||||
|     if (this.props.account !== nextProps.account && nextProps.account) { |     if (this.props.account !== nextProps.account && nextProps.account) { | ||||||
|       this.props.dispatch(refreshAccountTimeline(nextProps.account.get('id'))); |       this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'))); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,11 +1,22 @@ | ||||||
|  | import { injectIntl } from 'react-intl'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import { NotificationStack } from 'react-notification'; | import { NotificationStack } from 'react-notification'; | ||||||
| import { dismissAlert } from '../../../actions/alerts'; | import { dismissAlert } from '../../../actions/alerts'; | ||||||
| import { getAlerts } from '../../../selectors'; | import { getAlerts } from '../../../selectors'; | ||||||
| 
 | 
 | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = (state, { intl }) => { | ||||||
|   notifications: getAlerts(state), |   const notifications = getAlerts(state); | ||||||
| }); | 
 | ||||||
|  |   notifications.forEach(notification => ['title', 'message'].forEach(key => { | ||||||
|  |     const value = notification[key]; | ||||||
|  | 
 | ||||||
|  |     if (typeof value === 'object') { | ||||||
|  |       notification[key] = intl.formatMessage(value); | ||||||
|  |     } | ||||||
|  |   })); | ||||||
|  | 
 | ||||||
|  |   return { notifications }; | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| const mapDispatchToProps = (dispatch) => { | const mapDispatchToProps = (dispatch) => { | ||||||
|   return { |   return { | ||||||
|  | @ -15,4 +26,4 @@ const mapDispatchToProps = (dispatch) => { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack); | export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack)); | ||||||
|  |  | ||||||
|  | @ -48,15 +48,13 @@ const makeMapStateToProps = () => { | ||||||
|     statusIds: getStatusIds(state, { type: timelineId }), |     statusIds: getStatusIds(state, { type: timelineId }), | ||||||
|     isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), |     isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), | ||||||
|     isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), |     isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), | ||||||
|     hasMore: !!state.getIn(['timelines', timelineId, 'next']), |     hasMore:   state.getIn(['timelines', timelineId, 'hasMore']), | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   return mapStateToProps; |   return mapStateToProps; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const mapDispatchToProps = (dispatch, { timelineId, loadMore }) => ({ | const mapDispatchToProps = (dispatch, { timelineId }) => ({ | ||||||
| 
 |  | ||||||
|   onLoadMore: debounce(loadMore, 300, { leading: true }), |  | ||||||
| 
 | 
 | ||||||
|   onScrollToTop: debounce(() => { |   onScrollToTop: debounce(() => { | ||||||
|     dispatch(scrollTopTimeline(timelineId, true)); |     dispatch(scrollTopTimeline(timelineId, true)); | ||||||
|  |  | ||||||
|  | @ -10,8 +10,8 @@ import { Redirect, withRouter } from 'react-router-dom'; | ||||||
| import { isMobile } from '../../is_mobile'; | import { isMobile } from '../../is_mobile'; | ||||||
| import { debounce } from 'lodash'; | import { debounce } from 'lodash'; | ||||||
| import { uploadCompose, resetCompose } from '../../actions/compose'; | import { uploadCompose, resetCompose } from '../../actions/compose'; | ||||||
| import { refreshHomeTimeline } from '../../actions/timelines'; | import { expandHomeTimeline } from '../../actions/timelines'; | ||||||
| import { refreshNotifications } from '../../actions/notifications'; | import { expandNotifications } from '../../actions/notifications'; | ||||||
| import { clearHeight } from '../../actions/height_cache'; | import { clearHeight } from '../../actions/height_cache'; | ||||||
| import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; | import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; | ||||||
| import UploadArea from './components/upload_area'; | import UploadArea from './components/upload_area'; | ||||||
|  | @ -284,8 +284,8 @@ export default class UI extends React.PureComponent { | ||||||
|       navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); |       navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     this.props.dispatch(refreshHomeTimeline()); |     this.props.dispatch(expandHomeTimeline()); | ||||||
|     this.props.dispatch(refreshNotifications()); |     this.props.dispatch(expandNotifications()); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "إلغاء الكتم عن @{name}", |   "account.unmute": "إلغاء الكتم عن @{name}", | ||||||
|   "account.unmute_notifications": "إلغاء كتم إخطارات @{name}", |   "account.unmute_notifications": "إلغاء كتم إخطارات @{name}", | ||||||
|   "account.view_full_profile": "عرض الملف الشخصي كاملا", |   "account.view_full_profile": "عرض الملف الشخصي كاملا", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "يمكنك ضغط {combo} لتخطّي هذه في المرّة القادمة", |   "boost_modal.combo": "يمكنك ضغط {combo} لتخطّي هذه في المرّة القادمة", | ||||||
|   "bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.", |   "bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.", | ||||||
|   "bundle_column_error.retry": "إعادة المحاولة", |   "bundle_column_error.retry": "إعادة المحاولة", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Unmute @{name}", |   "account.unmute": "Unmute @{name}", | ||||||
|   "account.unmute_notifications": "Unmute notifications from @{name}", |   "account.unmute_notifications": "Unmute notifications from @{name}", | ||||||
|   "account.view_full_profile": "View full profile", |   "account.view_full_profile": "View full profile", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "You can press {combo} to skip this next time", |   "boost_modal.combo": "You can press {combo} to skip this next time", | ||||||
|   "bundle_column_error.body": "Something went wrong while loading this component.", |   "bundle_column_error.body": "Something went wrong while loading this component.", | ||||||
|   "bundle_column_error.retry": "Try again", |   "bundle_column_error.retry": "Try again", | ||||||
|  |  | ||||||
|  | @ -1,9 +1,9 @@ | ||||||
| { | { | ||||||
|   "account.block": "Bloca @{name}", |   "account.block": "Bloca @{name}", | ||||||
|   "account.block_domain": "Amaga-ho tot de {domain}", |   "account.block_domain": "Amaga-ho tot de {domain}", | ||||||
|   "account.blocked": "Blocked", |   "account.blocked": "Bloquejat", | ||||||
|   "account.disclaimer_full": "La informació següent pot reflectir incompleta el perfil de l'usuari.", |   "account.disclaimer_full": "La informació següent pot reflectir incompleta el perfil de l'usuari.", | ||||||
|   "account.domain_blocked": "Domain hidden", |   "account.domain_blocked": "Domini ocult", | ||||||
|   "account.edit_profile": "Edita el perfil", |   "account.edit_profile": "Edita el perfil", | ||||||
|   "account.follow": "Segueix", |   "account.follow": "Segueix", | ||||||
|   "account.followers": "Seguidors", |   "account.followers": "Seguidors", | ||||||
|  | @ -15,7 +15,7 @@ | ||||||
|   "account.moved_to": "{name} s'ha mogut a:", |   "account.moved_to": "{name} s'ha mogut a:", | ||||||
|   "account.mute": "Silencia @{name}", |   "account.mute": "Silencia @{name}", | ||||||
|   "account.mute_notifications": "Notificacions desactivades de @{name}", |   "account.mute_notifications": "Notificacions desactivades de @{name}", | ||||||
|   "account.muted": "Muted", |   "account.muted": "Silenciat", | ||||||
|   "account.posts": "Toots", |   "account.posts": "Toots", | ||||||
|   "account.posts_with_replies": "Toots amb respostes", |   "account.posts_with_replies": "Toots amb respostes", | ||||||
|   "account.report": "Informe @{name}", |   "account.report": "Informe @{name}", | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Treure silenci de @{name}", |   "account.unmute": "Treure silenci de @{name}", | ||||||
|   "account.unmute_notifications": "Activar notificacions de @{name}", |   "account.unmute_notifications": "Activar notificacions de @{name}", | ||||||
|   "account.view_full_profile": "Mostra el perfil complet", |   "account.view_full_profile": "Mostra el perfil complet", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Pots premer {combo} per saltar-te això el proper cop", |   "boost_modal.combo": "Pots premer {combo} per saltar-te això el proper cop", | ||||||
|   "bundle_column_error.body": "S'ha produït un error en carregar aquest component.", |   "bundle_column_error.body": "S'ha produït un error en carregar aquest component.", | ||||||
|   "bundle_column_error.retry": "Torna-ho a provar", |   "bundle_column_error.retry": "Torna-ho a provar", | ||||||
|  | @ -60,10 +62,10 @@ | ||||||
|   "compose_form.placeholder": "En què estàs pensant?", |   "compose_form.placeholder": "En què estàs pensant?", | ||||||
|   "compose_form.publish": "Toot", |   "compose_form.publish": "Toot", | ||||||
|   "compose_form.publish_loud": "{publish}!", |   "compose_form.publish_loud": "{publish}!", | ||||||
|   "compose_form.sensitive.marked": "Media is marked as sensitive", |   "compose_form.sensitive.marked": "Mèdia marcat com a sensible", | ||||||
|   "compose_form.sensitive.unmarked": "Media is not marked as sensitive", |   "compose_form.sensitive.unmarked": "Mèdia no està marcat com a sensible", | ||||||
|   "compose_form.spoiler.marked": "Text is hidden behind warning", |   "compose_form.spoiler.marked": "Text ocult sota l'avís", | ||||||
|   "compose_form.spoiler.unmarked": "Text is not hidden", |   "compose_form.spoiler.unmarked": "Text no ocult", | ||||||
|   "compose_form.spoiler_placeholder": "Escriu l'avís aquí", |   "compose_form.spoiler_placeholder": "Escriu l'avís aquí", | ||||||
|   "confirmation_modal.cancel": "Cancel·la", |   "confirmation_modal.cancel": "Cancel·la", | ||||||
|   "confirmations.block.confirm": "Bloca", |   "confirmations.block.confirm": "Bloca", | ||||||
|  | @ -221,7 +223,7 @@ | ||||||
|   "report.target": "Informes", |   "report.target": "Informes", | ||||||
|   "search.placeholder": "Cercar", |   "search.placeholder": "Cercar", | ||||||
|   "search_popout.search_format": "Format de cerca avançada", |   "search_popout.search_format": "Format de cerca avançada", | ||||||
|   "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", |   "search_popout.tips.full_text": "Text simple recupera publicacions que has escrit, les marcades com a favorites, les impulsades o en les que has estat esmentat, així com usuaris, noms d'usuari i etiquetes.", | ||||||
|   "search_popout.tips.hashtag": "etiqueta", |   "search_popout.tips.hashtag": "etiqueta", | ||||||
|   "search_popout.tips.status": "status", |   "search_popout.tips.status": "status", | ||||||
|   "search_popout.tips.text": "El text simple retorna coincidències amb els noms de visualització, els noms d'usuari i els hashtags", |   "search_popout.tips.text": "El text simple retorna coincidències amb els noms de visualització, els noms d'usuari i els hashtags", | ||||||
|  | @ -244,7 +246,7 @@ | ||||||
|   "status.mute_conversation": "Silenciar conversació", |   "status.mute_conversation": "Silenciar conversació", | ||||||
|   "status.open": "Ampliar aquest estat", |   "status.open": "Ampliar aquest estat", | ||||||
|   "status.pin": "Fixat en el perfil", |   "status.pin": "Fixat en el perfil", | ||||||
|   "status.pinned": "Pinned toot", |   "status.pinned": "Toot fixat", | ||||||
|   "status.reblog": "Impuls", |   "status.reblog": "Impuls", | ||||||
|   "status.reblogged_by": "{name} ha retootejat", |   "status.reblogged_by": "{name} ha retootejat", | ||||||
|   "status.reply": "Respondre", |   "status.reply": "Respondre", | ||||||
|  | @ -254,9 +256,9 @@ | ||||||
|   "status.sensitive_warning": "Contingut sensible", |   "status.sensitive_warning": "Contingut sensible", | ||||||
|   "status.share": "Compartir", |   "status.share": "Compartir", | ||||||
|   "status.show_less": "Mostra menys", |   "status.show_less": "Mostra menys", | ||||||
|   "status.show_less_all": "Show less for all", |   "status.show_less_all": "Mostra menys per a tot", | ||||||
|   "status.show_more": "Mostra més", |   "status.show_more": "Mostra més", | ||||||
|   "status.show_more_all": "Show more for all", |   "status.show_more_all": "Mostra més per a tot", | ||||||
|   "status.unmute_conversation": "Activar conversació", |   "status.unmute_conversation": "Activar conversació", | ||||||
|   "status.unpin": "Deslliga del perfil", |   "status.unpin": "Deslliga del perfil", | ||||||
|   "tabs_bar.federated_timeline": "Federada", |   "tabs_bar.federated_timeline": "Federada", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "@{name} nicht mehr stummschalten", |   "account.unmute": "@{name} nicht mehr stummschalten", | ||||||
|   "account.unmute_notifications": "Benachrichtigungen von @{name} einschalten", |   "account.unmute_notifications": "Benachrichtigungen von @{name} einschalten", | ||||||
|   "account.view_full_profile": "Vollständiges Profil anzeigen", |   "account.view_full_profile": "Vollständiges Profil anzeigen", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen", |   "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen", | ||||||
|   "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.", |   "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.", | ||||||
|   "bundle_column_error.retry": "Erneut versuchen", |   "bundle_column_error.retry": "Erneut versuchen", | ||||||
|  |  | ||||||
|  | @ -326,7 +326,7 @@ | ||||||
|         "id": "account.posts" |         "id": "account.posts" | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         "defaultMessage": "Toots with replies", |         "defaultMessage": "Toots and replies", | ||||||
|         "id": "account.posts_with_replies" |         "id": "account.posts_with_replies" | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|  | @ -1747,5 +1747,18 @@ | ||||||
|       } |       } | ||||||
|     ], |     ], | ||||||
|     "path": "app/javascript/mastodon/features/video/index.json" |     "path": "app/javascript/mastodon/features/video/index.json" | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     "descriptors": [ | ||||||
|  |       { | ||||||
|  |         "defaultMessage": "Oops!", | ||||||
|  |         "id": "alert.unexpected.title" | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "defaultMessage": "An unexpected error occurred.", | ||||||
|  |         "id": "alert.unexpected.message" | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     "path": "app/javascript/mastodon/middleware/errors.json" | ||||||
|   } |   } | ||||||
| ] | ] | ||||||
|  | @ -17,7 +17,7 @@ | ||||||
|   "account.mute_notifications": "Mute notifications from @{name}", |   "account.mute_notifications": "Mute notifications from @{name}", | ||||||
|   "account.muted": "Muted", |   "account.muted": "Muted", | ||||||
|   "account.posts": "Toots", |   "account.posts": "Toots", | ||||||
|   "account.posts_with_replies": "Toots with replies", |   "account.posts_with_replies": "Toots and replies", | ||||||
|   "account.report": "Report @{name}", |   "account.report": "Report @{name}", | ||||||
|   "account.requested": "Awaiting approval. Click to cancel follow request", |   "account.requested": "Awaiting approval. Click to cancel follow request", | ||||||
|   "account.share": "Share @{name}'s profile", |   "account.share": "Share @{name}'s profile", | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Unmute @{name}", |   "account.unmute": "Unmute @{name}", | ||||||
|   "account.unmute_notifications": "Unmute notifications from @{name}", |   "account.unmute_notifications": "Unmute notifications from @{name}", | ||||||
|   "account.view_full_profile": "View full profile", |   "account.view_full_profile": "View full profile", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "You can press {combo} to skip this next time", |   "boost_modal.combo": "You can press {combo} to skip this next time", | ||||||
|   "bundle_column_error.body": "Something went wrong while loading this component.", |   "bundle_column_error.body": "Something went wrong while loading this component.", | ||||||
|   "bundle_column_error.retry": "Try again", |   "bundle_column_error.retry": "Try again", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Malsilentigi @{name}", |   "account.unmute": "Malsilentigi @{name}", | ||||||
|   "account.unmute_notifications": "Malsilentigi sciigojn de @{name}", |   "account.unmute_notifications": "Malsilentigi sciigojn de @{name}", | ||||||
|   "account.view_full_profile": "Vidi plenan profilon", |   "account.view_full_profile": "Vidi plenan profilon", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Vi povas premi {combo} por preterpasi sekvafoje", |   "boost_modal.combo": "Vi povas premi {combo} por preterpasi sekvafoje", | ||||||
|   "bundle_column_error.body": "Io misfunkciis en la ŝargado de ĉi tiu elemento.", |   "bundle_column_error.body": "Io misfunkciis en la ŝargado de ĉi tiu elemento.", | ||||||
|   "bundle_column_error.retry": "Bonvolu reprovi", |   "bundle_column_error.retry": "Bonvolu reprovi", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Dejar de silenciar a @{name}", |   "account.unmute": "Dejar de silenciar a @{name}", | ||||||
|   "account.unmute_notifications": "Dejar de silenciar las notificaciones de @{name}", |   "account.unmute_notifications": "Dejar de silenciar las notificaciones de @{name}", | ||||||
|   "account.view_full_profile": "Ver perfil completo", |   "account.view_full_profile": "Ver perfil completo", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Puedes presionar {combo} para saltear este aviso la próxima vez", |   "boost_modal.combo": "Puedes presionar {combo} para saltear este aviso la próxima vez", | ||||||
|   "bundle_column_error.body": "Algo salió mal al cargar este componente.", |   "bundle_column_error.body": "Algo salió mal al cargar este componente.", | ||||||
|   "bundle_column_error.retry": "Inténtalo de nuevo", |   "bundle_column_error.retry": "Inténtalo de nuevo", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "باصدا کردن @{name}", |   "account.unmute": "باصدا کردن @{name}", | ||||||
|   "account.unmute_notifications": "باصداکردن اعلانها از طرف @{name}", |   "account.unmute_notifications": "باصداکردن اعلانها از طرف @{name}", | ||||||
|   "account.view_full_profile": "نمایش نمایهٔ کامل", |   "account.view_full_profile": "نمایش نمایهٔ کامل", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید", |   "boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید", | ||||||
|   "bundle_column_error.body": "هنگام بازکردن این بخش خطایی رخ داد.", |   "bundle_column_error.body": "هنگام بازکردن این بخش خطایی رخ داد.", | ||||||
|   "bundle_column_error.retry": "تلاش دوباره", |   "bundle_column_error.retry": "تلاش دوباره", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Poista mykistys käyttäjältä @{name}", |   "account.unmute": "Poista mykistys käyttäjältä @{name}", | ||||||
|   "account.unmute_notifications": "Poista mykistys käyttäjän @{name} ilmoituksilta", |   "account.unmute_notifications": "Poista mykistys käyttäjän @{name} ilmoituksilta", | ||||||
|   "account.view_full_profile": "Näytä koko profiili", |   "account.view_full_profile": "Näytä koko profiili", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Voit painaa näppäimiä {combo} ohittaaksesi tämän ensi kerralla", |   "boost_modal.combo": "Voit painaa näppäimiä {combo} ohittaaksesi tämän ensi kerralla", | ||||||
|   "bundle_column_error.body": "Jokin meni vikaan tätä komponenttia ladatessa.", |   "bundle_column_error.body": "Jokin meni vikaan tätä komponenttia ladatessa.", | ||||||
|   "bundle_column_error.retry": "Yritä uudestaan", |   "bundle_column_error.retry": "Yritä uudestaan", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Ne plus masquer", |   "account.unmute": "Ne plus masquer", | ||||||
|   "account.unmute_notifications": "Réactiver les notifications de @{name}", |   "account.unmute_notifications": "Réactiver les notifications de @{name}", | ||||||
|   "account.view_full_profile": "Afficher le profil complet", |   "account.view_full_profile": "Afficher le profil complet", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Vous pouvez appuyer sur {combo} pour pouvoir passer ceci, la prochaine fois", |   "boost_modal.combo": "Vous pouvez appuyer sur {combo} pour pouvoir passer ceci, la prochaine fois", | ||||||
|   "bundle_column_error.body": "Une erreur s’est produite lors du chargement de ce composant.", |   "bundle_column_error.body": "Une erreur s’est produite lors du chargement de ce composant.", | ||||||
|   "bundle_column_error.retry": "Réessayer", |   "bundle_column_error.retry": "Réessayer", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Non acalar @{name}", |   "account.unmute": "Non acalar @{name}", | ||||||
|   "account.unmute_notifications": "Desbloquear as notificacións de @{name}", |   "account.unmute_notifications": "Desbloquear as notificacións de @{name}", | ||||||
|   "account.view_full_profile": "Ver o perfil completo", |   "account.view_full_profile": "Ver o perfil completo", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Pulse {combo} para saltar esto a próxima vez", |   "boost_modal.combo": "Pulse {combo} para saltar esto a próxima vez", | ||||||
|   "bundle_column_error.body": "Houbo un fallo mentras se cargaba este compoñente.", |   "bundle_column_error.body": "Houbo un fallo mentras se cargaba este compoñente.", | ||||||
|   "bundle_column_error.retry": "Inténteo de novo", |   "bundle_column_error.retry": "Inténteo de novo", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "הפסקת השתקת @{name}", |   "account.unmute": "הפסקת השתקת @{name}", | ||||||
|   "account.unmute_notifications": "להפסיק הסתרת הודעות מעם @{name}", |   "account.unmute_notifications": "להפסיק הסתרת הודעות מעם @{name}", | ||||||
|   "account.view_full_profile": "הראה אודות מלאות", |   "account.view_full_profile": "הראה אודות מלאות", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "ניתן להקיש {combo} כדי לדלג בפעם הבאה", |   "boost_modal.combo": "ניתן להקיש {combo} כדי לדלג בפעם הבאה", | ||||||
|   "bundle_column_error.body": "משהו השתבש בעת הצגת הרכיב הזה.", |   "bundle_column_error.body": "משהו השתבש בעת הצגת הרכיב הזה.", | ||||||
|   "bundle_column_error.retry": "לנסות שוב", |   "bundle_column_error.retry": "לנסות שוב", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Poništi utišavanje @{name}", |   "account.unmute": "Poništi utišavanje @{name}", | ||||||
|   "account.unmute_notifications": "Unmute notifications from @{name}", |   "account.unmute_notifications": "Unmute notifications from @{name}", | ||||||
|   "account.view_full_profile": "View full profile", |   "account.view_full_profile": "View full profile", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Možeš pritisnuti {combo} kako bi ovo preskočio sljedeći put", |   "boost_modal.combo": "Možeš pritisnuti {combo} kako bi ovo preskočio sljedeći put", | ||||||
|   "bundle_column_error.body": "Something went wrong while loading this component.", |   "bundle_column_error.body": "Something went wrong while loading this component.", | ||||||
|   "bundle_column_error.retry": "Try again", |   "bundle_column_error.retry": "Try again", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "@{name} kinémítása", |   "account.unmute": "@{name} kinémítása", | ||||||
|   "account.unmute_notifications": "@{name} értesítéseinek kinémítása", |   "account.unmute_notifications": "@{name} értesítéseinek kinémítása", | ||||||
|   "account.view_full_profile": "Teljes profil megtekintése", |   "account.view_full_profile": "Teljes profil megtekintése", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Megnyomhatod {combo}, hogy átugord következő alkalommal", |   "boost_modal.combo": "Megnyomhatod {combo}, hogy átugord következő alkalommal", | ||||||
|   "bundle_column_error.body": "Hiba történt a komponens betöltése közben.", |   "bundle_column_error.body": "Hiba történt a komponens betöltése közben.", | ||||||
|   "bundle_column_error.retry": "Próbálja újra", |   "bundle_column_error.retry": "Próbálja újra", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Ապալռեցնել @{name}֊ին", |   "account.unmute": "Ապալռեցնել @{name}֊ին", | ||||||
|   "account.unmute_notifications": "Միացնել ծանուցումները @{name}֊ից", |   "account.unmute_notifications": "Միացնել ծանուցումները @{name}֊ից", | ||||||
|   "account.view_full_profile": "Դիտել ամբողջական տարբերակը։", |   "account.view_full_profile": "Դիտել ամբողջական տարբերակը։", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Կարող ես սեղմել {combo}՝ սա հաջորդ անգամ բաց թողնելու համար", |   "boost_modal.combo": "Կարող ես սեղմել {combo}՝ սա հաջորդ անգամ բաց թողնելու համար", | ||||||
|   "bundle_column_error.body": "Այս բաղադրիչը բեռնելու ընթացքում ինչ֊որ բան խափանվեց։", |   "bundle_column_error.body": "Այս բաղադրիչը բեռնելու ընթացքում ինչ֊որ բան խափանվեց։", | ||||||
|   "bundle_column_error.retry": "Կրկին փորձել", |   "bundle_column_error.retry": "Կրկին փորձել", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Berhenti membisukan @{name}", |   "account.unmute": "Berhenti membisukan @{name}", | ||||||
|   "account.unmute_notifications": "Munculkan notifikasi dari @{name}", |   "account.unmute_notifications": "Munculkan notifikasi dari @{name}", | ||||||
|   "account.view_full_profile": "Lihat profil lengkap", |   "account.view_full_profile": "Lihat profil lengkap", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Anda dapat menekan {combo} untuk melewati ini", |   "boost_modal.combo": "Anda dapat menekan {combo} untuk melewati ini", | ||||||
|   "bundle_column_error.body": "Kesalahan terjadi saat memuat komponen ini.", |   "bundle_column_error.body": "Kesalahan terjadi saat memuat komponen ini.", | ||||||
|   "bundle_column_error.retry": "Coba lagi", |   "bundle_column_error.retry": "Coba lagi", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Ne plus celar @{name}", |   "account.unmute": "Ne plus celar @{name}", | ||||||
|   "account.unmute_notifications": "Unmute notifications from @{name}", |   "account.unmute_notifications": "Unmute notifications from @{name}", | ||||||
|   "account.view_full_profile": "View full profile", |   "account.view_full_profile": "View full profile", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Tu povas presar sur {combo} por omisar co en la venonta foyo", |   "boost_modal.combo": "Tu povas presar sur {combo} por omisar co en la venonta foyo", | ||||||
|   "bundle_column_error.body": "Something went wrong while loading this component.", |   "bundle_column_error.body": "Something went wrong while loading this component.", | ||||||
|   "bundle_column_error.retry": "Try again", |   "bundle_column_error.retry": "Try again", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Non silenziare @{name}", |   "account.unmute": "Non silenziare @{name}", | ||||||
|   "account.unmute_notifications": "Unmute notifications from @{name}", |   "account.unmute_notifications": "Unmute notifications from @{name}", | ||||||
|   "account.view_full_profile": "View full profile", |   "account.view_full_profile": "View full profile", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Puoi premere {combo} per saltare questo passaggio la prossima volta", |   "boost_modal.combo": "Puoi premere {combo} per saltare questo passaggio la prossima volta", | ||||||
|   "bundle_column_error.body": "Something went wrong while loading this component.", |   "bundle_column_error.body": "Something went wrong while loading this component.", | ||||||
|   "bundle_column_error.retry": "Try again", |   "bundle_column_error.retry": "Try again", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "@{name}さんのミュートを解除", |   "account.unmute": "@{name}さんのミュートを解除", | ||||||
|   "account.unmute_notifications": "@{name}さんからの通知を受け取る", |   "account.unmute_notifications": "@{name}さんからの通知を受け取る", | ||||||
|   "account.view_full_profile": "全ての情報を見る", |   "account.view_full_profile": "全ての情報を見る", | ||||||
|  |   "alert.unexpected.message": "不明なエラーが発生しました", | ||||||
|  |   "alert.unexpected.title": "エラー", | ||||||
|   "boost_modal.combo": "次からは{combo}を押せばスキップできます", |   "boost_modal.combo": "次からは{combo}を押せばスキップできます", | ||||||
|   "bundle_column_error.body": "コンポーネントの読み込み中に問題が発生しました。", |   "bundle_column_error.body": "コンポーネントの読み込み中に問題が発生しました。", | ||||||
|   "bundle_column_error.retry": "再試行", |   "bundle_column_error.retry": "再試行", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "뮤트 해제", |   "account.unmute": "뮤트 해제", | ||||||
|   "account.unmute_notifications": "@{name}의 알림 뮤트 해제", |   "account.unmute_notifications": "@{name}의 알림 뮤트 해제", | ||||||
|   "account.view_full_profile": "전체 프로필 보기", |   "account.view_full_profile": "전체 프로필 보기", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "다음부터 {combo}를 누르면 이 과정을 건너뛸 수 있습니다.", |   "boost_modal.combo": "다음부터 {combo}를 누르면 이 과정을 건너뛸 수 있습니다.", | ||||||
|   "bundle_column_error.body": "컴포넌트를 불러오는 과정에서 문제가 발생했습니다.", |   "bundle_column_error.body": "컴포넌트를 불러오는 과정에서 문제가 발생했습니다.", | ||||||
|   "bundle_column_error.retry": "다시 시도", |   "bundle_column_error.retry": "다시 시도", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "@{name} niet meer negeren", |   "account.unmute": "@{name} niet meer negeren", | ||||||
|   "account.unmute_notifications": "@{name} meldingen niet meer negeren", |   "account.unmute_notifications": "@{name} meldingen niet meer negeren", | ||||||
|   "account.view_full_profile": "Volledig profiel tonen", |   "account.view_full_profile": "Volledig profiel tonen", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan", |   "boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan", | ||||||
|   "bundle_column_error.body": "Tijdens het laden van dit onderdeel is er iets fout gegaan.", |   "bundle_column_error.body": "Tijdens het laden van dit onderdeel is er iets fout gegaan.", | ||||||
|   "bundle_column_error.retry": "Opnieuw proberen", |   "bundle_column_error.retry": "Opnieuw proberen", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Avdemp @{name}", |   "account.unmute": "Avdemp @{name}", | ||||||
|   "account.unmute_notifications": "Vis varsler fra @{name}", |   "account.unmute_notifications": "Vis varsler fra @{name}", | ||||||
|   "account.view_full_profile": "Vis hele profilen", |   "account.view_full_profile": "Vis hele profilen", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "You kan trykke {combo} for å hoppe over dette neste gang", |   "boost_modal.combo": "You kan trykke {combo} for å hoppe over dette neste gang", | ||||||
|   "bundle_column_error.body": "Noe gikk galt mens denne komponenten lastet.", |   "bundle_column_error.body": "Noe gikk galt mens denne komponenten lastet.", | ||||||
|   "bundle_column_error.retry": "Prøv igjen", |   "bundle_column_error.retry": "Prøv igjen", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Quitar de rescondre @{name}", |   "account.unmute": "Quitar de rescondre @{name}", | ||||||
|   "account.unmute_notifications": "Mostrar las notificacions de @{name}", |   "account.unmute_notifications": "Mostrar las notificacions de @{name}", | ||||||
|   "account.view_full_profile": "Veire lo perfil complèt", |   "account.view_full_profile": "Veire lo perfil complèt", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Podètz botar {combo} per passar aquò lo còp que ven", |   "boost_modal.combo": "Podètz botar {combo} per passar aquò lo còp que ven", | ||||||
|   "bundle_column_error.body": "Quicòm a fach mèuca pendent lo cargament d’aqueste compausant.", |   "bundle_column_error.body": "Quicòm a fach mèuca pendent lo cargament d’aqueste compausant.", | ||||||
|   "bundle_column_error.retry": "Tornar ensajar", |   "bundle_column_error.retry": "Tornar ensajar", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Cofnij wyciszenie @{name}", |   "account.unmute": "Cofnij wyciszenie @{name}", | ||||||
|   "account.unmute_notifications": "Cofnij wyciszenie powiadomień od @{name}", |   "account.unmute_notifications": "Cofnij wyciszenie powiadomień od @{name}", | ||||||
|   "account.view_full_profile": "Wyświetl pełny profil", |   "account.view_full_profile": "Wyświetl pełny profil", | ||||||
|  |   "alert.unexpected.message": "Wystąpił nieoczekiwany błąd.", | ||||||
|  |   "alert.unexpected.title": "O nie!", | ||||||
|   "boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem", |   "boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem", | ||||||
|   "bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.", |   "bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.", | ||||||
|   "bundle_column_error.retry": "Spróbuj ponownie", |   "bundle_column_error.retry": "Spróbuj ponownie", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Não silenciar @{name}", |   "account.unmute": "Não silenciar @{name}", | ||||||
|   "account.unmute_notifications": "Retirar silêncio das notificações vindas de @{name}", |   "account.unmute_notifications": "Retirar silêncio das notificações vindas de @{name}", | ||||||
|   "account.view_full_profile": "Ver perfil completo", |   "account.view_full_profile": "Ver perfil completo", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Você pode pressionar {combo} para ignorar este diálogo na próxima vez", |   "boost_modal.combo": "Você pode pressionar {combo} para ignorar este diálogo na próxima vez", | ||||||
|   "bundle_column_error.body": "Algo de errado aconteceu enquanto este componente era carregado.", |   "bundle_column_error.body": "Algo de errado aconteceu enquanto este componente era carregado.", | ||||||
|   "bundle_column_error.retry": "Tente novamente", |   "bundle_column_error.retry": "Tente novamente", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Não silenciar @{name}", |   "account.unmute": "Não silenciar @{name}", | ||||||
|   "account.unmute_notifications": "Deixar de silenciar @{name}", |   "account.unmute_notifications": "Deixar de silenciar @{name}", | ||||||
|   "account.view_full_profile": "Ver perfil completo", |   "account.view_full_profile": "Ver perfil completo", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Pode clicar {combo} para não voltar a ver", |   "boost_modal.combo": "Pode clicar {combo} para não voltar a ver", | ||||||
|   "bundle_column_error.body": "Algo de errado aconteceu enquanto este componente era carregado.", |   "bundle_column_error.body": "Algo de errado aconteceu enquanto este componente era carregado.", | ||||||
|   "bundle_column_error.retry": "Tente de novo", |   "bundle_column_error.retry": "Tente de novo", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Снять глушение", |   "account.unmute": "Снять глушение", | ||||||
|   "account.unmute_notifications": "Показывать уведомления от @{name}", |   "account.unmute_notifications": "Показывать уведомления от @{name}", | ||||||
|   "account.view_full_profile": "Показать полный профиль", |   "account.view_full_profile": "Показать полный профиль", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз", |   "boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз", | ||||||
|   "bundle_column_error.body": "Что-то пошло не так при загрузке этого компонента.", |   "bundle_column_error.body": "Что-то пошло не так при загрузке этого компонента.", | ||||||
|   "bundle_column_error.retry": "Попробовать снова", |   "bundle_column_error.retry": "Попробовать снова", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Prestať ignorovať @{name}", |   "account.unmute": "Prestať ignorovať @{name}", | ||||||
|   "account.unmute_notifications": "Odtĺmiť notifikácie od @{name}", |   "account.unmute_notifications": "Odtĺmiť notifikácie od @{name}", | ||||||
|   "account.view_full_profile": "Pozri celý profil", |   "account.view_full_profile": "Pozri celý profil", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Nabudúce môžete kliknúť {combo} aby ste preskočili", |   "boost_modal.combo": "Nabudúce môžete kliknúť {combo} aby ste preskočili", | ||||||
|   "bundle_column_error.body": "Nastala chyba pri načítaní tohto komponentu.", |   "bundle_column_error.body": "Nastala chyba pri načítaní tohto komponentu.", | ||||||
|   "bundle_column_error.retry": "Skúste znova", |   "bundle_column_error.retry": "Skúste znova", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Ukloni ućutkavanje korisniku @{name}", |   "account.unmute": "Ukloni ućutkavanje korisniku @{name}", | ||||||
|   "account.unmute_notifications": "Uključi nazad obaveštenja od korisnika @{name}", |   "account.unmute_notifications": "Uključi nazad obaveštenja od korisnika @{name}", | ||||||
|   "account.view_full_profile": "Vidi ceo profil", |   "account.view_full_profile": "Vidi ceo profil", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Možete pritisnuti {combo} da preskočite ovo sledeći put", |   "boost_modal.combo": "Možete pritisnuti {combo} da preskočite ovo sledeći put", | ||||||
|   "bundle_column_error.body": "Nešto je pošlo po zlu prilikom učitavanja ove komponente.", |   "bundle_column_error.body": "Nešto je pošlo po zlu prilikom učitavanja ove komponente.", | ||||||
|   "bundle_column_error.retry": "Pokušajte ponovo", |   "bundle_column_error.retry": "Pokušajte ponovo", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Уклони ућуткавање кориснику @{name}", |   "account.unmute": "Уклони ућуткавање кориснику @{name}", | ||||||
|   "account.unmute_notifications": "Укључи назад обавештења од корисника @{name}", |   "account.unmute_notifications": "Укључи назад обавештења од корисника @{name}", | ||||||
|   "account.view_full_profile": "Види цео профил", |   "account.view_full_profile": "Види цео профил", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Можете притиснути {combo} да прескочите ово следећи пут", |   "boost_modal.combo": "Можете притиснути {combo} да прескочите ово следећи пут", | ||||||
|   "bundle_column_error.body": "Нешто је пошло по злу приликом учитавања ове компоненте.", |   "bundle_column_error.body": "Нешто је пошло по злу приликом учитавања ове компоненте.", | ||||||
|   "bundle_column_error.retry": "Покушајте поново", |   "bundle_column_error.retry": "Покушајте поново", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Ta bort tystad @{name}", |   "account.unmute": "Ta bort tystad @{name}", | ||||||
|   "account.unmute_notifications": "Återaktivera notifikationer från @{name}", |   "account.unmute_notifications": "Återaktivera notifikationer från @{name}", | ||||||
|   "account.view_full_profile": "Visa hela profilen", |   "account.view_full_profile": "Visa hela profilen", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Du kan trycka {combo} för att slippa denna nästa gång", |   "boost_modal.combo": "Du kan trycka {combo} för att slippa denna nästa gång", | ||||||
|   "bundle_column_error.body": "Något gick fel när du laddade denna komponent.", |   "bundle_column_error.body": "Något gick fel när du laddade denna komponent.", | ||||||
|   "bundle_column_error.retry": "Försök igen", |   "bundle_column_error.retry": "Försök igen", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Unmute @{name}", |   "account.unmute": "Unmute @{name}", | ||||||
|   "account.unmute_notifications": "Unmute notifications from @{name}", |   "account.unmute_notifications": "Unmute notifications from @{name}", | ||||||
|   "account.view_full_profile": "View full profile", |   "account.view_full_profile": "View full profile", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "You can press {combo} to skip this next time", |   "boost_modal.combo": "You can press {combo} to skip this next time", | ||||||
|   "bundle_column_error.body": "Something went wrong while loading this component.", |   "bundle_column_error.body": "Something went wrong while loading this component.", | ||||||
|   "bundle_column_error.retry": "Try again", |   "bundle_column_error.retry": "Try again", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Sesi aç @{name}", |   "account.unmute": "Sesi aç @{name}", | ||||||
|   "account.unmute_notifications": "Unmute notifications from @{name}", |   "account.unmute_notifications": "Unmute notifications from @{name}", | ||||||
|   "account.view_full_profile": "View full profile", |   "account.view_full_profile": "View full profile", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Bir dahaki sefere {combo} tuşuna basabilirsiniz", |   "boost_modal.combo": "Bir dahaki sefere {combo} tuşuna basabilirsiniz", | ||||||
|   "bundle_column_error.body": "Something went wrong while loading this component.", |   "bundle_column_error.body": "Something went wrong while loading this component.", | ||||||
|   "bundle_column_error.retry": "Try again", |   "bundle_column_error.retry": "Try again", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "Зняти глушення", |   "account.unmute": "Зняти глушення", | ||||||
|   "account.unmute_notifications": "Unmute notifications from @{name}", |   "account.unmute_notifications": "Unmute notifications from @{name}", | ||||||
|   "account.view_full_profile": "View full profile", |   "account.view_full_profile": "View full profile", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "Ви можете натиснути {combo}, щоб пропустити це наступного разу", |   "boost_modal.combo": "Ви можете натиснути {combo}, щоб пропустити це наступного разу", | ||||||
|   "bundle_column_error.body": "Something went wrong while loading this component.", |   "bundle_column_error.body": "Something went wrong while loading this component.", | ||||||
|   "bundle_column_error.retry": "Try again", |   "bundle_column_error.retry": "Try again", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "不再隐藏 @{name}", |   "account.unmute": "不再隐藏 @{name}", | ||||||
|   "account.unmute_notifications": "不再隐藏来自 @{name} 的通知", |   "account.unmute_notifications": "不再隐藏来自 @{name} 的通知", | ||||||
|   "account.view_full_profile": "查看完整资料", |   "account.view_full_profile": "查看完整资料", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "下次按住 {combo} 即可跳过此提示", |   "boost_modal.combo": "下次按住 {combo} 即可跳过此提示", | ||||||
|   "bundle_column_error.body": "载入这个组件时发生了错误。", |   "bundle_column_error.body": "载入这个组件时发生了错误。", | ||||||
|   "bundle_column_error.retry": "重试", |   "bundle_column_error.retry": "重试", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "取消 @{name} 的靜音", |   "account.unmute": "取消 @{name} 的靜音", | ||||||
|   "account.unmute_notifications": "Unmute notifications from @{name}", |   "account.unmute_notifications": "Unmute notifications from @{name}", | ||||||
|   "account.view_full_profile": "查看完整資料", |   "account.view_full_profile": "查看完整資料", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "如你想在下次路過這顯示,請按{combo},", |   "boost_modal.combo": "如你想在下次路過這顯示,請按{combo},", | ||||||
|   "bundle_column_error.body": "加載本組件出錯。", |   "bundle_column_error.body": "加載本組件出錯。", | ||||||
|   "bundle_column_error.retry": "重試", |   "bundle_column_error.retry": "重試", | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ | ||||||
|   "account.unmute": "不再消音 @{name}", |   "account.unmute": "不再消音 @{name}", | ||||||
|   "account.unmute_notifications": "Unmute notifications from @{name}", |   "account.unmute_notifications": "Unmute notifications from @{name}", | ||||||
|   "account.view_full_profile": "查看完整資訊", |   "account.view_full_profile": "查看完整資訊", | ||||||
|  |   "alert.unexpected.message": "An unexpected error occurred.", | ||||||
|  |   "alert.unexpected.title": "Oops!", | ||||||
|   "boost_modal.combo": "下次你可以按 {combo} 來跳過", |   "boost_modal.combo": "下次你可以按 {combo} 來跳過", | ||||||
|   "bundle_column_error.body": "加載本組件出錯。", |   "bundle_column_error.body": "加載本組件出錯。", | ||||||
|   "bundle_column_error.retry": "重試", |   "bundle_column_error.retry": "重試", | ||||||
|  |  | ||||||
|  | @ -1,7 +1,13 @@ | ||||||
|  | import { defineMessages } from 'react-intl'; | ||||||
| import { showAlert } from '../actions/alerts'; | import { showAlert } from '../actions/alerts'; | ||||||
| 
 | 
 | ||||||
| const defaultFailSuffix = 'FAIL'; | const defaultFailSuffix = 'FAIL'; | ||||||
| 
 | 
 | ||||||
|  | const messages = defineMessages({ | ||||||
|  |   unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, | ||||||
|  |   unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| export default function errorsMiddleware() { | export default function errorsMiddleware() { | ||||||
|   return ({ dispatch }) => next => action => { |   return ({ dispatch }) => next => action => { | ||||||
|     if (action.type && !action.skipAlert) { |     if (action.type && !action.skipAlert) { | ||||||
|  | @ -21,7 +27,7 @@ export default function errorsMiddleware() { | ||||||
|           dispatch(showAlert(title, message)); |           dispatch(showAlert(title, message)); | ||||||
|         } else { |         } else { | ||||||
|           console.error(action.error); |           console.error(action.error); | ||||||
|           dispatch(showAlert('Oops!', 'An unexpected error occurred.')); |           dispatch(showAlert(messages.unexpectedTitle, messages.unexpectedMessage)); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -1,56 +1,7 @@ | ||||||
| import { | import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer'; | ||||||
|   ACCOUNT_FETCH_SUCCESS, |  | ||||||
|   FOLLOWERS_FETCH_SUCCESS, |  | ||||||
|   FOLLOWERS_EXPAND_SUCCESS, |  | ||||||
|   FOLLOWING_FETCH_SUCCESS, |  | ||||||
|   FOLLOWING_EXPAND_SUCCESS, |  | ||||||
|   FOLLOW_REQUESTS_FETCH_SUCCESS, |  | ||||||
|   FOLLOW_REQUESTS_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/accounts'; |  | ||||||
| import { |  | ||||||
|   BLOCKS_FETCH_SUCCESS, |  | ||||||
|   BLOCKS_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/blocks'; |  | ||||||
| import { |  | ||||||
|   MUTES_FETCH_SUCCESS, |  | ||||||
|   MUTES_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/mutes'; |  | ||||||
| import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose'; |  | ||||||
| import { |  | ||||||
|   REBLOG_SUCCESS, |  | ||||||
|   UNREBLOG_SUCCESS, |  | ||||||
|   FAVOURITE_SUCCESS, |  | ||||||
|   UNFAVOURITE_SUCCESS, |  | ||||||
|   REBLOGS_FETCH_SUCCESS, |  | ||||||
|   FAVOURITES_FETCH_SUCCESS, |  | ||||||
| } from '../actions/interactions'; |  | ||||||
| import { |  | ||||||
|   TIMELINE_REFRESH_SUCCESS, |  | ||||||
|   TIMELINE_UPDATE, |  | ||||||
|   TIMELINE_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/timelines'; |  | ||||||
| import { |  | ||||||
|   STATUS_FETCH_SUCCESS, |  | ||||||
|   CONTEXT_FETCH_SUCCESS, |  | ||||||
| } from '../actions/statuses'; |  | ||||||
| import { SEARCH_FETCH_SUCCESS } from '../actions/search'; |  | ||||||
| import { |  | ||||||
|   NOTIFICATIONS_UPDATE, |  | ||||||
|   NOTIFICATIONS_REFRESH_SUCCESS, |  | ||||||
|   NOTIFICATIONS_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/notifications'; |  | ||||||
| import { |  | ||||||
|   FAVOURITED_STATUSES_FETCH_SUCCESS, |  | ||||||
|   FAVOURITED_STATUSES_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/favourites'; |  | ||||||
| import { |  | ||||||
|   LIST_ACCOUNTS_FETCH_SUCCESS, |  | ||||||
|   LIST_EDITOR_SUGGESTIONS_READY, |  | ||||||
| } from '../actions/lists'; |  | ||||||
| import { STORE_HYDRATE } from '../actions/store'; |  | ||||||
| import emojify from '../features/emoji/emoji'; |  | ||||||
| import { Map as ImmutableMap, fromJS } from 'immutable'; | import { Map as ImmutableMap, fromJS } from 'immutable'; | ||||||
| import escapeTextContentForBrowser from 'escape-html'; | 
 | ||||||
|  | const initialState = ImmutableMap(); | ||||||
| 
 | 
 | ||||||
| const normalizeAccount = (state, account) => { | const normalizeAccount = (state, account) => { | ||||||
|   account = { ...account }; |   account = { ...account }; | ||||||
|  | @ -59,15 +10,6 @@ const normalizeAccount = (state, account) => { | ||||||
|   delete account.following_count; |   delete account.following_count; | ||||||
|   delete account.statuses_count; |   delete account.statuses_count; | ||||||
| 
 | 
 | ||||||
|   const displayName = account.display_name.length === 0 ? account.username : account.display_name; |  | ||||||
|   account.display_name_html = emojify(escapeTextContentForBrowser(displayName)); |  | ||||||
|   account.note_emojified = emojify(account.note); |  | ||||||
| 
 |  | ||||||
|   if (account.moved) { |  | ||||||
|     state = normalizeAccount(state, account.moved); |  | ||||||
|     account.moved = account.moved.id; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return state.set(account.id, fromJS(account)); |   return state.set(account.id, fromJS(account)); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -79,67 +21,12 @@ const normalizeAccounts = (state, accounts) => { | ||||||
|   return state; |   return state; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const normalizeAccountFromStatus = (state, status) => { |  | ||||||
|   state = normalizeAccount(state, status.account); |  | ||||||
| 
 |  | ||||||
|   if (status.reblog && status.reblog.account) { |  | ||||||
|     state = normalizeAccount(state, status.reblog.account); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return state; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const normalizeAccountsFromStatuses = (state, statuses) => { |  | ||||||
|   statuses.forEach(status => { |  | ||||||
|     state = normalizeAccountFromStatus(state, status); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   return state; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const initialState = ImmutableMap(); |  | ||||||
| 
 |  | ||||||
| export default function accounts(state = initialState, action) { | export default function accounts(state = initialState, action) { | ||||||
|   switch(action.type) { |   switch(action.type) { | ||||||
|   case STORE_HYDRATE: |   case ACCOUNT_IMPORT: | ||||||
|     return normalizeAccounts(state, Object.values(action.state.get('accounts').toJS())); |  | ||||||
|   case ACCOUNT_FETCH_SUCCESS: |  | ||||||
|   case NOTIFICATIONS_UPDATE: |  | ||||||
|     return normalizeAccount(state, action.account); |     return normalizeAccount(state, action.account); | ||||||
|   case FOLLOWERS_FETCH_SUCCESS: |   case ACCOUNTS_IMPORT: | ||||||
|   case FOLLOWERS_EXPAND_SUCCESS: |     return normalizeAccounts(state, action.accounts); | ||||||
|   case FOLLOWING_FETCH_SUCCESS: |  | ||||||
|   case FOLLOWING_EXPAND_SUCCESS: |  | ||||||
|   case REBLOGS_FETCH_SUCCESS: |  | ||||||
|   case FAVOURITES_FETCH_SUCCESS: |  | ||||||
|   case COMPOSE_SUGGESTIONS_READY: |  | ||||||
|   case FOLLOW_REQUESTS_FETCH_SUCCESS: |  | ||||||
|   case FOLLOW_REQUESTS_EXPAND_SUCCESS: |  | ||||||
|   case BLOCKS_FETCH_SUCCESS: |  | ||||||
|   case BLOCKS_EXPAND_SUCCESS: |  | ||||||
|   case MUTES_FETCH_SUCCESS: |  | ||||||
|   case MUTES_EXPAND_SUCCESS: |  | ||||||
|   case LIST_ACCOUNTS_FETCH_SUCCESS: |  | ||||||
|   case LIST_EDITOR_SUGGESTIONS_READY: |  | ||||||
|     return action.accounts ? normalizeAccounts(state, action.accounts) : state; |  | ||||||
|   case NOTIFICATIONS_REFRESH_SUCCESS: |  | ||||||
|   case NOTIFICATIONS_EXPAND_SUCCESS: |  | ||||||
|   case SEARCH_FETCH_SUCCESS: |  | ||||||
|     return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); |  | ||||||
|   case TIMELINE_REFRESH_SUCCESS: |  | ||||||
|   case TIMELINE_EXPAND_SUCCESS: |  | ||||||
|   case CONTEXT_FETCH_SUCCESS: |  | ||||||
|   case FAVOURITED_STATUSES_FETCH_SUCCESS: |  | ||||||
|   case FAVOURITED_STATUSES_EXPAND_SUCCESS: |  | ||||||
|     return normalizeAccountsFromStatuses(state, action.statuses); |  | ||||||
|   case REBLOG_SUCCESS: |  | ||||||
|   case FAVOURITE_SUCCESS: |  | ||||||
|   case UNREBLOG_SUCCESS: |  | ||||||
|   case UNFAVOURITE_SUCCESS: |  | ||||||
|     return normalizeAccountFromStatus(state, action.response); |  | ||||||
|   case TIMELINE_UPDATE: |  | ||||||
|   case STATUS_FETCH_SUCCESS: |  | ||||||
|     return normalizeAccountFromStatus(state, action.status); |  | ||||||
|   default: |   default: | ||||||
|     return state; |     return state; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -1,55 +1,8 @@ | ||||||
| import { | import { | ||||||
|   ACCOUNT_FETCH_SUCCESS, |  | ||||||
|   FOLLOWERS_FETCH_SUCCESS, |  | ||||||
|   FOLLOWERS_EXPAND_SUCCESS, |  | ||||||
|   FOLLOWING_FETCH_SUCCESS, |  | ||||||
|   FOLLOWING_EXPAND_SUCCESS, |  | ||||||
|   FOLLOW_REQUESTS_FETCH_SUCCESS, |  | ||||||
|   FOLLOW_REQUESTS_EXPAND_SUCCESS, |  | ||||||
|   ACCOUNT_FOLLOW_SUCCESS, |   ACCOUNT_FOLLOW_SUCCESS, | ||||||
|   ACCOUNT_UNFOLLOW_SUCCESS, |   ACCOUNT_UNFOLLOW_SUCCESS, | ||||||
| } from '../actions/accounts'; | } from '../actions/accounts'; | ||||||
| import { | import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer'; | ||||||
|   BLOCKS_FETCH_SUCCESS, |  | ||||||
|   BLOCKS_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/blocks'; |  | ||||||
| import { |  | ||||||
|   MUTES_FETCH_SUCCESS, |  | ||||||
|   MUTES_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/mutes'; |  | ||||||
| import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose'; |  | ||||||
| import { |  | ||||||
|   REBLOG_SUCCESS, |  | ||||||
|   UNREBLOG_SUCCESS, |  | ||||||
|   FAVOURITE_SUCCESS, |  | ||||||
|   UNFAVOURITE_SUCCESS, |  | ||||||
|   REBLOGS_FETCH_SUCCESS, |  | ||||||
|   FAVOURITES_FETCH_SUCCESS, |  | ||||||
| } from '../actions/interactions'; |  | ||||||
| import { |  | ||||||
|   TIMELINE_REFRESH_SUCCESS, |  | ||||||
|   TIMELINE_UPDATE, |  | ||||||
|   TIMELINE_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/timelines'; |  | ||||||
| import { |  | ||||||
|   STATUS_FETCH_SUCCESS, |  | ||||||
|   CONTEXT_FETCH_SUCCESS, |  | ||||||
| } from '../actions/statuses'; |  | ||||||
| import { SEARCH_FETCH_SUCCESS } from '../actions/search'; |  | ||||||
| import { |  | ||||||
|   NOTIFICATIONS_UPDATE, |  | ||||||
|   NOTIFICATIONS_REFRESH_SUCCESS, |  | ||||||
|   NOTIFICATIONS_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/notifications'; |  | ||||||
| import { |  | ||||||
|   FAVOURITED_STATUSES_FETCH_SUCCESS, |  | ||||||
|   FAVOURITED_STATUSES_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/favourites'; |  | ||||||
| import { |  | ||||||
|   LIST_ACCOUNTS_FETCH_SUCCESS, |  | ||||||
|   LIST_EDITOR_SUGGESTIONS_READY, |  | ||||||
| } from '../actions/lists'; |  | ||||||
| import { STORE_HYDRATE } from '../actions/store'; |  | ||||||
| import { Map as ImmutableMap, fromJS } from 'immutable'; | import { Map as ImmutableMap, fromJS } from 'immutable'; | ||||||
| 
 | 
 | ||||||
| const normalizeAccount = (state, account) => state.set(account.id, fromJS({ | const normalizeAccount = (state, account) => state.set(account.id, fromJS({ | ||||||
|  | @ -66,71 +19,14 @@ const normalizeAccounts = (state, accounts) => { | ||||||
|   return state; |   return state; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const normalizeAccountFromStatus = (state, status) => { |  | ||||||
|   state = normalizeAccount(state, status.account); |  | ||||||
| 
 |  | ||||||
|   if (status.reblog && status.reblog.account) { |  | ||||||
|     state = normalizeAccount(state, status.reblog.account); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return state; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const normalizeAccountsFromStatuses = (state, statuses) => { |  | ||||||
|   statuses.forEach(status => { |  | ||||||
|     state = normalizeAccountFromStatus(state, status); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   return state; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const initialState = ImmutableMap(); | const initialState = ImmutableMap(); | ||||||
| 
 | 
 | ||||||
| export default function accountsCounters(state = initialState, action) { | export default function accountsCounters(state = initialState, action) { | ||||||
|   switch(action.type) { |   switch(action.type) { | ||||||
|   case STORE_HYDRATE: |   case ACCOUNT_IMPORT: | ||||||
|     return state.merge(action.state.get('accounts').map(item => fromJS({ |  | ||||||
|       followers_count: item.get('followers_count'), |  | ||||||
|       following_count: item.get('following_count'), |  | ||||||
|       statuses_count: item.get('statuses_count'), |  | ||||||
|     }))); |  | ||||||
|   case ACCOUNT_FETCH_SUCCESS: |  | ||||||
|   case NOTIFICATIONS_UPDATE: |  | ||||||
|     return normalizeAccount(state, action.account); |     return normalizeAccount(state, action.account); | ||||||
|   case FOLLOWERS_FETCH_SUCCESS: |   case ACCOUNTS_IMPORT: | ||||||
|   case FOLLOWERS_EXPAND_SUCCESS: |     return normalizeAccounts(state, action.accounts); | ||||||
|   case FOLLOWING_FETCH_SUCCESS: |  | ||||||
|   case FOLLOWING_EXPAND_SUCCESS: |  | ||||||
|   case REBLOGS_FETCH_SUCCESS: |  | ||||||
|   case FAVOURITES_FETCH_SUCCESS: |  | ||||||
|   case COMPOSE_SUGGESTIONS_READY: |  | ||||||
|   case FOLLOW_REQUESTS_FETCH_SUCCESS: |  | ||||||
|   case FOLLOW_REQUESTS_EXPAND_SUCCESS: |  | ||||||
|   case BLOCKS_FETCH_SUCCESS: |  | ||||||
|   case BLOCKS_EXPAND_SUCCESS: |  | ||||||
|   case MUTES_FETCH_SUCCESS: |  | ||||||
|   case MUTES_EXPAND_SUCCESS: |  | ||||||
|   case LIST_ACCOUNTS_FETCH_SUCCESS: |  | ||||||
|   case LIST_EDITOR_SUGGESTIONS_READY: |  | ||||||
|     return action.accounts ? normalizeAccounts(state, action.accounts) : state; |  | ||||||
|   case NOTIFICATIONS_REFRESH_SUCCESS: |  | ||||||
|   case NOTIFICATIONS_EXPAND_SUCCESS: |  | ||||||
|   case SEARCH_FETCH_SUCCESS: |  | ||||||
|     return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); |  | ||||||
|   case TIMELINE_REFRESH_SUCCESS: |  | ||||||
|   case TIMELINE_EXPAND_SUCCESS: |  | ||||||
|   case CONTEXT_FETCH_SUCCESS: |  | ||||||
|   case FAVOURITED_STATUSES_FETCH_SUCCESS: |  | ||||||
|   case FAVOURITED_STATUSES_EXPAND_SUCCESS: |  | ||||||
|     return normalizeAccountsFromStatuses(state, action.statuses); |  | ||||||
|   case REBLOG_SUCCESS: |  | ||||||
|   case FAVOURITE_SUCCESS: |  | ||||||
|   case UNREBLOG_SUCCESS: |  | ||||||
|   case UNFAVOURITE_SUCCESS: |  | ||||||
|     return normalizeAccountFromStatus(state, action.response); |  | ||||||
|   case TIMELINE_UPDATE: |  | ||||||
|   case STATUS_FETCH_SUCCESS: |  | ||||||
|     return normalizeAccountFromStatus(state, action.status); |  | ||||||
|   case ACCOUNT_FOLLOW_SUCCESS: |   case ACCOUNT_FOLLOW_SUCCESS: | ||||||
|     return action.alreadyFollowing ? state : |     return action.alreadyFollowing ? state : | ||||||
|       state.updateIn([action.relationship.id, 'followers_count'], num => num + 1); |       state.updateIn([action.relationship.id, 'followers_count'], num => num + 1); | ||||||
|  |  | ||||||
|  | @ -1,10 +1,7 @@ | ||||||
| import { | import { | ||||||
|   NOTIFICATIONS_UPDATE, |   NOTIFICATIONS_UPDATE, | ||||||
|   NOTIFICATIONS_REFRESH_SUCCESS, |  | ||||||
|   NOTIFICATIONS_EXPAND_SUCCESS, |   NOTIFICATIONS_EXPAND_SUCCESS, | ||||||
|   NOTIFICATIONS_REFRESH_REQUEST, |  | ||||||
|   NOTIFICATIONS_EXPAND_REQUEST, |   NOTIFICATIONS_EXPAND_REQUEST, | ||||||
|   NOTIFICATIONS_REFRESH_FAIL, |  | ||||||
|   NOTIFICATIONS_EXPAND_FAIL, |   NOTIFICATIONS_EXPAND_FAIL, | ||||||
|   NOTIFICATIONS_CLEAR, |   NOTIFICATIONS_CLEAR, | ||||||
|   NOTIFICATIONS_SCROLL_TOP, |   NOTIFICATIONS_SCROLL_TOP, | ||||||
|  | @ -13,16 +10,15 @@ import { | ||||||
|   ACCOUNT_BLOCK_SUCCESS, |   ACCOUNT_BLOCK_SUCCESS, | ||||||
|   ACCOUNT_MUTE_SUCCESS, |   ACCOUNT_MUTE_SUCCESS, | ||||||
| } from '../actions/accounts'; | } from '../actions/accounts'; | ||||||
| import { TIMELINE_DELETE } from '../actions/timelines'; | import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines'; | ||||||
| import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | ||||||
| 
 | 
 | ||||||
| const initialState = ImmutableMap({ | const initialState = ImmutableMap({ | ||||||
|   items: ImmutableList(), |   items: ImmutableList(), | ||||||
|   next: null, |   hasMore: true, | ||||||
|   top: true, |   top: true, | ||||||
|   unread: 0, |   unread: 0, | ||||||
|   loaded: false, |   isLoading: false, | ||||||
|   isLoading: true, |  | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const notificationToMap = notification => ImmutableMap({ | const notificationToMap = notification => ImmutableMap({ | ||||||
|  | @ -48,35 +44,41 @@ const normalizeNotification = (state, notification) => { | ||||||
|   }); |   }); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const normalizeNotifications = (state, notifications, next) => { | const newer = (m, n) => { | ||||||
|   let items    = ImmutableList(); |   const mId = m.get('id'); | ||||||
|   const loaded = state.get('loaded'); |   const nId = n.get('id'); | ||||||
| 
 | 
 | ||||||
|   notifications.forEach((n, i) => { |   return mId.length === nId.length ? mId > nId : mId.length > nId.length; | ||||||
|     items = items.set(i, notificationToMap(n)); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   if (state.get('next') === null) { |  | ||||||
|     state = state.set('next', next); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return state |  | ||||||
|     .update('items', list => loaded ? items.concat(list) : list.concat(items)) |  | ||||||
|     .set('loaded', true) |  | ||||||
|     .set('isLoading', false); |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const appendNormalizedNotifications = (state, notifications, next) => { | const expandNormalizedNotifications = (state, notifications, next) => { | ||||||
|   let items = ImmutableList(); |   let items = ImmutableList(); | ||||||
| 
 | 
 | ||||||
|   notifications.forEach((n, i) => { |   notifications.forEach((n, i) => { | ||||||
|     items = items.set(i, notificationToMap(n)); |     items = items.set(i, notificationToMap(n)); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   return state |   return state.withMutations(mutable => { | ||||||
|     .update('items', list => list.concat(items)) |     if (!items.isEmpty()) { | ||||||
|     .set('next', next) |       mutable.update('items', list => { | ||||||
|     .set('isLoading', false); |         const lastIndex = 1 + list.findLastIndex( | ||||||
|  |           item => item !== null && (newer(item, items.last()) || item.get('id') === items.last().get('id')) | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         const firstIndex = 1 + list.take(lastIndex).findLastIndex( | ||||||
|  |           item => item !== null && newer(item, items.first()) | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         return list.take(firstIndex).concat(items, list.skip(lastIndex)); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (!next) { | ||||||
|  |       mutable.set('hasMore', true); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     mutable.set('isLoading', false); | ||||||
|  |   }); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const filterNotifications = (state, relationship) => { | const filterNotifications = (state, relationship) => { | ||||||
|  | @ -97,27 +99,27 @@ const deleteByStatus = (state, statusId) => { | ||||||
| 
 | 
 | ||||||
| export default function notifications(state = initialState, action) { | export default function notifications(state = initialState, action) { | ||||||
|   switch(action.type) { |   switch(action.type) { | ||||||
|   case NOTIFICATIONS_REFRESH_REQUEST: |  | ||||||
|   case NOTIFICATIONS_EXPAND_REQUEST: |   case NOTIFICATIONS_EXPAND_REQUEST: | ||||||
|     return state.set('isLoading', true); |     return state.set('isLoading', true); | ||||||
|   case NOTIFICATIONS_REFRESH_FAIL: |  | ||||||
|   case NOTIFICATIONS_EXPAND_FAIL: |   case NOTIFICATIONS_EXPAND_FAIL: | ||||||
|     return state.set('isLoading', false); |     return state.set('isLoading', false); | ||||||
|   case NOTIFICATIONS_SCROLL_TOP: |   case NOTIFICATIONS_SCROLL_TOP: | ||||||
|     return updateTop(state, action.top); |     return updateTop(state, action.top); | ||||||
|   case NOTIFICATIONS_UPDATE: |   case NOTIFICATIONS_UPDATE: | ||||||
|     return normalizeNotification(state, action.notification); |     return normalizeNotification(state, action.notification); | ||||||
|   case NOTIFICATIONS_REFRESH_SUCCESS: |  | ||||||
|     return normalizeNotifications(state, action.notifications, action.next); |  | ||||||
|   case NOTIFICATIONS_EXPAND_SUCCESS: |   case NOTIFICATIONS_EXPAND_SUCCESS: | ||||||
|     return appendNormalizedNotifications(state, action.notifications, action.next); |     return expandNormalizedNotifications(state, action.notifications, action.next); | ||||||
|   case ACCOUNT_BLOCK_SUCCESS: |   case ACCOUNT_BLOCK_SUCCESS: | ||||||
|   case ACCOUNT_MUTE_SUCCESS: |   case ACCOUNT_MUTE_SUCCESS: | ||||||
|     return filterNotifications(state, action.relationship); |     return filterNotifications(state, action.relationship); | ||||||
|   case NOTIFICATIONS_CLEAR: |   case NOTIFICATIONS_CLEAR: | ||||||
|     return state.set('items', ImmutableList()).set('next', null); |     return state.set('items', ImmutableList()).set('hasMore', false); | ||||||
|   case TIMELINE_DELETE: |   case TIMELINE_DELETE: | ||||||
|     return deleteByStatus(state, action.id); |     return deleteByStatus(state, action.id); | ||||||
|  |   case TIMELINE_DISCONNECT: | ||||||
|  |     return action.timeline === 'home' ? | ||||||
|  |       state.update('items', items => items.first() ? items.unshift(null) : items) : | ||||||
|  |       state; | ||||||
|   default: |   default: | ||||||
|     return state; |     return state; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -1,87 +1,23 @@ | ||||||
| import { | import { | ||||||
|   REBLOG_REQUEST, |   REBLOG_REQUEST, | ||||||
|   REBLOG_SUCCESS, |  | ||||||
|   REBLOG_FAIL, |   REBLOG_FAIL, | ||||||
|   UNREBLOG_SUCCESS, |  | ||||||
|   FAVOURITE_REQUEST, |   FAVOURITE_REQUEST, | ||||||
|   FAVOURITE_SUCCESS, |  | ||||||
|   FAVOURITE_FAIL, |   FAVOURITE_FAIL, | ||||||
|   UNFAVOURITE_SUCCESS, |  | ||||||
|   PIN_SUCCESS, |  | ||||||
|   UNPIN_SUCCESS, |  | ||||||
| } from '../actions/interactions'; | } from '../actions/interactions'; | ||||||
| import { | import { | ||||||
|   STATUS_FETCH_SUCCESS, |  | ||||||
|   CONTEXT_FETCH_SUCCESS, |  | ||||||
|   STATUS_MUTE_SUCCESS, |   STATUS_MUTE_SUCCESS, | ||||||
|   STATUS_UNMUTE_SUCCESS, |   STATUS_UNMUTE_SUCCESS, | ||||||
|   STATUS_REVEAL, |   STATUS_REVEAL, | ||||||
|   STATUS_HIDE, |   STATUS_HIDE, | ||||||
| } from '../actions/statuses'; | } from '../actions/statuses'; | ||||||
| import { | import { TIMELINE_DELETE } from '../actions/timelines'; | ||||||
|   TIMELINE_REFRESH_SUCCESS, | import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; | ||||||
|   TIMELINE_UPDATE, |  | ||||||
|   TIMELINE_DELETE, |  | ||||||
|   TIMELINE_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/timelines'; |  | ||||||
| import { |  | ||||||
|   NOTIFICATIONS_UPDATE, |  | ||||||
|   NOTIFICATIONS_REFRESH_SUCCESS, |  | ||||||
|   NOTIFICATIONS_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/notifications'; |  | ||||||
| import { |  | ||||||
|   FAVOURITED_STATUSES_FETCH_SUCCESS, |  | ||||||
|   FAVOURITED_STATUSES_EXPAND_SUCCESS, |  | ||||||
| } from '../actions/favourites'; |  | ||||||
| import { |  | ||||||
|   PINNED_STATUSES_FETCH_SUCCESS, |  | ||||||
| } from '../actions/pin_statuses'; |  | ||||||
| import { SEARCH_FETCH_SUCCESS } from '../actions/search'; |  | ||||||
| import emojify from '../features/emoji/emoji'; |  | ||||||
| import { Map as ImmutableMap, fromJS } from 'immutable'; | import { Map as ImmutableMap, fromJS } from 'immutable'; | ||||||
| import escapeTextContentForBrowser from 'escape-html'; |  | ||||||
| 
 | 
 | ||||||
| const domParser = new DOMParser(); | const importStatus = (state, status) => state.set(status.id, fromJS(status)); | ||||||
| 
 | 
 | ||||||
| const normalizeStatus = (state, status) => { | const importStatuses = (state, statuses) => | ||||||
|   if (!status) { |   state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status))); | ||||||
|     return state; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const normalStatus   = { ...status }; |  | ||||||
|   normalStatus.account = status.account.id; |  | ||||||
| 
 |  | ||||||
|   if (status.reblog && status.reblog.id) { |  | ||||||
|     state               = normalizeStatus(state, status.reblog); |  | ||||||
|     normalStatus.reblog = status.reblog.id; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   // Only calculate these values when status first encountered
 |  | ||||||
|   // Otherwise keep the ones already in the reducer
 |  | ||||||
|   if (!state.has(status.id)) { |  | ||||||
|     const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); |  | ||||||
| 
 |  | ||||||
|     const emojiMap = normalStatus.emojis.reduce((obj, emoji) => { |  | ||||||
|       obj[`:${emoji.shortcode}:`] = emoji; |  | ||||||
|       return obj; |  | ||||||
|     }, {}); |  | ||||||
| 
 |  | ||||||
|     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; |  | ||||||
|     normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap); |  | ||||||
|     normalStatus.spoilerHtml  = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap); |  | ||||||
|     normalStatus.hidden       = normalStatus.sensitive; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus))); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const normalizeStatuses = (state, statuses) => { |  | ||||||
|   statuses.forEach(status => { |  | ||||||
|     state = normalizeStatus(state, status); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   return state; |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| const deleteStatus = (state, id, references) => { | const deleteStatus = (state, id, references) => { | ||||||
|   references.forEach(ref => { |   references.forEach(ref => { | ||||||
|  | @ -95,17 +31,10 @@ const initialState = ImmutableMap(); | ||||||
| 
 | 
 | ||||||
| export default function statuses(state = initialState, action) { | export default function statuses(state = initialState, action) { | ||||||
|   switch(action.type) { |   switch(action.type) { | ||||||
|   case TIMELINE_UPDATE: |   case STATUS_IMPORT: | ||||||
|   case STATUS_FETCH_SUCCESS: |     return importStatus(state, action.status); | ||||||
|   case NOTIFICATIONS_UPDATE: |   case STATUSES_IMPORT: | ||||||
|     return normalizeStatus(state, action.status); |     return importStatuses(state, action.statuses); | ||||||
|   case REBLOG_SUCCESS: |  | ||||||
|   case UNREBLOG_SUCCESS: |  | ||||||
|   case FAVOURITE_SUCCESS: |  | ||||||
|   case UNFAVOURITE_SUCCESS: |  | ||||||
|   case PIN_SUCCESS: |  | ||||||
|   case UNPIN_SUCCESS: |  | ||||||
|     return normalizeStatus(state, action.response); |  | ||||||
|   case FAVOURITE_REQUEST: |   case FAVOURITE_REQUEST: | ||||||
|     return state.setIn([action.status.get('id'), 'favourited'], true); |     return state.setIn([action.status.get('id'), 'favourited'], true); | ||||||
|   case FAVOURITE_FAIL: |   case FAVOURITE_FAIL: | ||||||
|  | @ -126,16 +55,6 @@ export default function statuses(state = initialState, action) { | ||||||
|     return state.withMutations(map => { |     return state.withMutations(map => { | ||||||
|       action.ids.forEach(id => map.setIn([id, 'hidden'], true)); |       action.ids.forEach(id => map.setIn([id, 'hidden'], true)); | ||||||
|     }); |     }); | ||||||
|   case TIMELINE_REFRESH_SUCCESS: |  | ||||||
|   case TIMELINE_EXPAND_SUCCESS: |  | ||||||
|   case CONTEXT_FETCH_SUCCESS: |  | ||||||
|   case NOTIFICATIONS_REFRESH_SUCCESS: |  | ||||||
|   case NOTIFICATIONS_EXPAND_SUCCESS: |  | ||||||
|   case FAVOURITED_STATUSES_FETCH_SUCCESS: |  | ||||||
|   case FAVOURITED_STATUSES_EXPAND_SUCCESS: |  | ||||||
|   case PINNED_STATUSES_FETCH_SUCCESS: |  | ||||||
|   case SEARCH_FETCH_SUCCESS: |  | ||||||
|     return normalizeStatuses(state, action.statuses); |  | ||||||
|   case TIMELINE_DELETE: |   case TIMELINE_DELETE: | ||||||
|     return deleteStatus(state, action.id, action.references); |     return deleteStatus(state, action.id, action.references); | ||||||
|   default: |   default: | ||||||
|  |  | ||||||
|  | @ -1,14 +1,10 @@ | ||||||
| import { | import { | ||||||
|   TIMELINE_REFRESH_REQUEST, |  | ||||||
|   TIMELINE_REFRESH_SUCCESS, |  | ||||||
|   TIMELINE_REFRESH_FAIL, |  | ||||||
|   TIMELINE_UPDATE, |   TIMELINE_UPDATE, | ||||||
|   TIMELINE_DELETE, |   TIMELINE_DELETE, | ||||||
|   TIMELINE_EXPAND_SUCCESS, |   TIMELINE_EXPAND_SUCCESS, | ||||||
|   TIMELINE_EXPAND_REQUEST, |   TIMELINE_EXPAND_REQUEST, | ||||||
|   TIMELINE_EXPAND_FAIL, |   TIMELINE_EXPAND_FAIL, | ||||||
|   TIMELINE_SCROLL_TOP, |   TIMELINE_SCROLL_TOP, | ||||||
|   TIMELINE_CONNECT, |  | ||||||
|   TIMELINE_DISCONNECT, |   TIMELINE_DISCONNECT, | ||||||
| } from '../actions/timelines'; | } from '../actions/timelines'; | ||||||
| import { | import { | ||||||
|  | @ -22,37 +18,33 @@ const initialState = ImmutableMap(); | ||||||
| 
 | 
 | ||||||
| const initialTimeline = ImmutableMap({ | const initialTimeline = ImmutableMap({ | ||||||
|   unread: 0, |   unread: 0, | ||||||
|   online: false, |  | ||||||
|   top: true, |   top: true, | ||||||
|   loaded: false, |  | ||||||
|   isLoading: false, |   isLoading: false, | ||||||
|   next: false, |   hasMore: true, | ||||||
|   items: ImmutableList(), |   items: ImmutableList(), | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const normalizeTimeline = (state, timeline, statuses, next, isPartial) => { | const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial) => { | ||||||
|   const oldIds    = state.getIn([timeline, 'items'], ImmutableList()); |  | ||||||
|   const ids       = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId)); |  | ||||||
|   const wasLoaded = state.getIn([timeline, 'loaded']); |  | ||||||
|   const hadNext   = state.getIn([timeline, 'next']); |  | ||||||
| 
 |  | ||||||
|   return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { |  | ||||||
|     mMap.set('loaded', true); |  | ||||||
|     mMap.set('isLoading', false); |  | ||||||
|     if (!hadNext) mMap.set('next', next); |  | ||||||
|     mMap.set('items', wasLoaded ? ids.concat(oldIds) : oldIds.concat(ids)); |  | ||||||
|     mMap.set('isPartial', isPartial); |  | ||||||
|   })); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const appendNormalizedTimeline = (state, timeline, statuses, next) => { |  | ||||||
|   const oldIds = state.getIn([timeline, 'items'], ImmutableList()); |  | ||||||
|   const ids    = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId)); |  | ||||||
| 
 |  | ||||||
|   return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { |   return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { | ||||||
|     mMap.set('isLoading', false); |     mMap.set('isLoading', false); | ||||||
|     mMap.set('next', next); |     if (!next) mMap.set('hasMore', false); | ||||||
|     mMap.set('items', oldIds.concat(ids)); | 
 | ||||||
|  |     if (!statuses.isEmpty()) { | ||||||
|  |       mMap.update('items', ImmutableList(), oldIds => { | ||||||
|  |         const newIds = statuses.map(status => status.get('id')); | ||||||
|  |         const lastIndex = oldIds.findLastIndex(id => id !== null && id >= newIds.last()) + 1; | ||||||
|  |         const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && id > newIds.first()); | ||||||
|  | 
 | ||||||
|  |         if (firstIndex < 0) { | ||||||
|  |           return (isPartial ? newIds.unshift(null) : newIds).concat(oldIds.skip(lastIndex)); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return oldIds.take(firstIndex + 1).concat( | ||||||
|  |           isPartial && oldIds.get(firstIndex) !== null ? newIds.unshift(null) : newIds, | ||||||
|  |           oldIds.skip(lastIndex) | ||||||
|  |         ); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|   })); |   })); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -118,16 +110,12 @@ const updateTop = (state, timeline, top) => { | ||||||
| 
 | 
 | ||||||
| export default function timelines(state = initialState, action) { | export default function timelines(state = initialState, action) { | ||||||
|   switch(action.type) { |   switch(action.type) { | ||||||
|   case TIMELINE_REFRESH_REQUEST: |  | ||||||
|   case TIMELINE_EXPAND_REQUEST: |   case TIMELINE_EXPAND_REQUEST: | ||||||
|     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true)); |     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true)); | ||||||
|   case TIMELINE_REFRESH_FAIL: |  | ||||||
|   case TIMELINE_EXPAND_FAIL: |   case TIMELINE_EXPAND_FAIL: | ||||||
|     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false)); |     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false)); | ||||||
|   case TIMELINE_REFRESH_SUCCESS: |  | ||||||
|     return normalizeTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial); |  | ||||||
|   case TIMELINE_EXPAND_SUCCESS: |   case TIMELINE_EXPAND_SUCCESS: | ||||||
|     return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next); |     return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial); | ||||||
|   case TIMELINE_UPDATE: |   case TIMELINE_UPDATE: | ||||||
|     return updateTimeline(state, action.timeline, fromJS(action.status)); |     return updateTimeline(state, action.timeline, fromJS(action.status)); | ||||||
|   case TIMELINE_DELETE: |   case TIMELINE_DELETE: | ||||||
|  | @ -139,10 +127,15 @@ export default function timelines(state = initialState, action) { | ||||||
|     return filterTimeline('home', state, action.relationship, action.statuses); |     return filterTimeline('home', state, action.relationship, action.statuses); | ||||||
|   case TIMELINE_SCROLL_TOP: |   case TIMELINE_SCROLL_TOP: | ||||||
|     return updateTop(state, action.timeline, action.top); |     return updateTop(state, action.timeline, action.top); | ||||||
|   case TIMELINE_CONNECT: |  | ||||||
|     return state.update(action.timeline, initialTimeline, map => map.set('online', true)); |  | ||||||
|   case TIMELINE_DISCONNECT: |   case TIMELINE_DISCONNECT: | ||||||
|     return state.update(action.timeline, initialTimeline, map => map.set('online', false)); |     return state.update( | ||||||
|  |       action.timeline, | ||||||
|  |       initialTimeline, | ||||||
|  |       map => map.update( | ||||||
|  |         'items', | ||||||
|  |         items => items.first() ? items : items.unshift(null) | ||||||
|  |       ) | ||||||
|  |     ); | ||||||
|   default: |   default: | ||||||
|     return state; |     return state; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -1,10 +1,10 @@ | ||||||
| import WebSocketClient from 'websocket.js'; | import WebSocketClient from 'websocket.js'; | ||||||
| 
 | 
 | ||||||
| export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) { | export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onDisconnect() {}, onReceive() {} })) { | ||||||
|   return (dispatch, getState) => { |   return (dispatch, getState) => { | ||||||
|     const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); |     const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); | ||||||
|     const accessToken = getState().getIn(['meta', 'access_token']); |     const accessToken = getState().getIn(['meta', 'access_token']); | ||||||
|     const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState); |     const { onDisconnect, onReceive } = callbacks(dispatch, getState); | ||||||
|     let polling = null; |     let polling = null; | ||||||
| 
 | 
 | ||||||
|     const setupPolling = () => { |     const setupPolling = () => { | ||||||
|  | @ -25,7 +25,6 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ | ||||||
|         if (pollingRefresh) { |         if (pollingRefresh) { | ||||||
|           clearPolling(); |           clearPolling(); | ||||||
|         } |         } | ||||||
|         onConnect(); |  | ||||||
|       }, |       }, | ||||||
| 
 | 
 | ||||||
|       disconnected () { |       disconnected () { | ||||||
|  | @ -44,7 +43,6 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ | ||||||
|           clearPolling(); |           clearPolling(); | ||||||
|           pollingRefresh(dispatch); |           pollingRefresh(dispatch); | ||||||
|         } |         } | ||||||
|         onConnect(); |  | ||||||
|       }, |       }, | ||||||
| 
 | 
 | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  | @ -7,7 +7,6 @@ function main() { | ||||||
|   const { getLocale } = require('../mastodon/locales'); |   const { getLocale } = require('../mastodon/locales'); | ||||||
|   const { localeData } = getLocale(); |   const { localeData } = getLocale(); | ||||||
|   const VideoContainer = require('../mastodon/containers/video_container').default; |   const VideoContainer = require('../mastodon/containers/video_container').default; | ||||||
|   const MediaGalleryContainer = require('../mastodon/containers/media_gallery_container').default; |  | ||||||
|   const CardContainer = require('../mastodon/containers/card_container').default; |   const CardContainer = require('../mastodon/containers/card_container').default; | ||||||
|   const React = require('react'); |   const React = require('react'); | ||||||
|   const ReactDOM = require('react-dom'); |   const ReactDOM = require('react-dom'); | ||||||
|  | @ -58,15 +57,20 @@ function main() { | ||||||
|       ReactDOM.render(<VideoContainer locale={locale} {...props} />, content); |       ReactDOM.render(<VideoContainer locale={locale} {...props} />, content); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     [].forEach.call(document.querySelectorAll('[data-component="MediaGallery"]'), (content) => { |  | ||||||
|       const props = JSON.parse(content.getAttribute('data-props')); |  | ||||||
|       ReactDOM.render(<MediaGalleryContainer locale={locale} {...props} />, content); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     [].forEach.call(document.querySelectorAll('[data-component="Card"]'), (content) => { |     [].forEach.call(document.querySelectorAll('[data-component="Card"]'), (content) => { | ||||||
|       const props = JSON.parse(content.getAttribute('data-props')); |       const props = JSON.parse(content.getAttribute('data-props')); | ||||||
|       ReactDOM.render(<CardContainer locale={locale} {...props} />, content); |       ReactDOM.render(<CardContainer locale={locale} {...props} />, content); | ||||||
|     }); |     }); | ||||||
|  | 
 | ||||||
|  |     const mediaGalleries = document.querySelectorAll('[data-component="MediaGallery"]'); | ||||||
|  | 
 | ||||||
|  |     if (mediaGalleries.length > 0) { | ||||||
|  |       const MediaGalleriesContainer = require('../mastodon/containers/media_galleries_container').default; | ||||||
|  |       const content = document.createElement('div'); | ||||||
|  | 
 | ||||||
|  |       ReactDOM.render(<MediaGalleriesContainer locale={locale} galleries={mediaGalleries} />, content); | ||||||
|  |       document.body.appendChild(content); | ||||||
|  |     } | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -3375,13 +3375,14 @@ a.status-card { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .modal-root { | .modal-root { | ||||||
|  |   position: relative; | ||||||
|   transition: opacity 0.3s linear; |   transition: opacity 0.3s linear; | ||||||
|   will-change: opacity; |   will-change: opacity; | ||||||
|   z-index: 9999; |   z-index: 9999; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .modal-root__overlay { | .modal-root__overlay { | ||||||
|   position: absolute; |   position: fixed; | ||||||
|   top: 0; |   top: 0; | ||||||
|   left: 0; |   left: 0; | ||||||
|   right: 0; |   right: 0; | ||||||
|  | @ -3390,7 +3391,7 @@ a.status-card { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .modal-root__container { | .modal-root__container { | ||||||
|   position: absolute; |   position: fixed; | ||||||
|   top: 0; |   top: 0; | ||||||
|   left: 0; |   left: 0; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|  |  | ||||||
|  | @ -60,6 +60,10 @@ | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .media-gallery-standalone__body { | ||||||
|  |   overflow: hidden; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .account-header { | .account-header { | ||||||
|   width: 400px; |   width: 400px; | ||||||
|   margin: 0 auto; |   margin: 0 auto; | ||||||
|  |  | ||||||
|  | @ -13,15 +13,14 @@ class ProviderDiscovery < OEmbed::ProviderDiscovery | ||||||
|     def discover_provider(url, **options) |     def discover_provider(url, **options) | ||||||
|       format = options[:format] |       format = options[:format] | ||||||
| 
 | 
 | ||||||
|       if options[:html] |       html = if options[:html] | ||||||
|         html = Nokogiri::HTML(options[:html]) |                Nokogiri::HTML(options[:html]) | ||||||
|       else |              else | ||||||
|         res = Request.new(:get, url).perform |                Request.new(:get, url).perform do |res| | ||||||
| 
 |                  raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html' | ||||||
|         raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html' |                  Nokogiri::HTML(res.to_s) | ||||||
| 
 |                end | ||||||
|         html = Nokogiri::HTML(res.to_s) |              end | ||||||
|       end |  | ||||||
| 
 | 
 | ||||||
|       if format.nil? || format == :json |       if format.nil? || format == :json | ||||||
|         provider_endpoint ||= html.at_xpath('//link[@type="application/json+oembed"]')&.attribute('href')&.value |         provider_endpoint ||= html.at_xpath('//link[@type="application/json+oembed"]')&.attribute('href')&.value | ||||||
|  |  | ||||||
|  | @ -33,9 +33,17 @@ class Request | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def perform |   def perform | ||||||
|     http_client.headers(headers).public_send(@verb, @url.to_s, @options) |     begin | ||||||
|   rescue => e |       response = http_client.headers(headers).public_send(@verb, @url.to_s, @options) | ||||||
|     raise e.class, "#{e.message} on #{@url}", e.backtrace[0] |     rescue => e | ||||||
|  |       raise e.class, "#{e.message} on #{@url}", e.backtrace[0] | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     begin | ||||||
|  |       yield response | ||||||
|  |     ensure | ||||||
|  |       http_client.close | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def headers |   def headers | ||||||
|  | @ -88,7 +96,7 @@ class Request | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def http_client |   def http_client | ||||||
|     HTTP.timeout(:per_operation, timeout).follow(max_hops: 2) |     @http_client ||= HTTP.timeout(:per_operation, timeout).follow(max_hops: 2) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   class Socket < TCPSocket |   class Socket < TCPSocket | ||||||
|  |  | ||||||
|  | @ -21,23 +21,23 @@ module Remotable | ||||||
|         return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[attribute_name] == url |         return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[attribute_name] == url | ||||||
| 
 | 
 | ||||||
|         begin |         begin | ||||||
|           response = Request.new(:get, url).perform |           Request.new(:get, url).perform do |response| | ||||||
|  |             next if response.code != 200 | ||||||
| 
 | 
 | ||||||
|           return if response.code != 200 |             matches  = response.headers['content-disposition']&.match(/filename="([^"]*)"/) | ||||||
|  |             filename = matches.nil? ? parsed_url.path.split('/').last : matches[1] | ||||||
|  |             basename = SecureRandom.hex(8) | ||||||
|  |             extname = if filename.nil? | ||||||
|  |                         '' | ||||||
|  |                       else | ||||||
|  |                         File.extname(filename) | ||||||
|  |                       end | ||||||
| 
 | 
 | ||||||
|           matches  = response.headers['content-disposition']&.match(/filename="([^"]*)"/) |             send("#{attachment_name}=", StringIO.new(response.to_s)) | ||||||
|           filename = matches.nil? ? parsed_url.path.split('/').last : matches[1] |             send("#{attachment_name}_file_name=", basename + extname) | ||||||
|           basename = SecureRandom.hex(8) |  | ||||||
|           extname = if filename.nil? |  | ||||||
|                       '' |  | ||||||
|                     else |  | ||||||
|                       File.extname(filename) |  | ||||||
|                     end |  | ||||||
| 
 | 
 | ||||||
|           send("#{attachment_name}=", StringIO.new(response.to_s)) |             self[attribute_name] = url if has_attribute?(attribute_name) | ||||||
|           send("#{attachment_name}_file_name=", basename + extname) |           end | ||||||
| 
 |  | ||||||
|           self[attribute_name] = url if has_attribute?(attribute_name) |  | ||||||
|         rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError => e |         rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError => e | ||||||
|           Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}" |           Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}" | ||||||
|           nil |           nil | ||||||
|  |  | ||||||
|  | @ -4,12 +4,12 @@ | ||||||
| # Table name: notifications | # Table name: notifications | ||||||
| # | # | ||||||
| #  id              :integer          not null, primary key | #  id              :integer          not null, primary key | ||||||
| #  activity_id     :integer | #  activity_id     :integer          not null | ||||||
| #  activity_type   :string | #  activity_type   :string           not null | ||||||
| #  created_at      :datetime         not null | #  created_at      :datetime         not null | ||||||
| #  updated_at      :datetime         not null | #  updated_at      :datetime         not null | ||||||
| #  account_id      :integer | #  account_id      :integer          not null | ||||||
| #  from_account_id :integer | #  from_account_id :integer          not null | ||||||
| # | # | ||||||
| 
 | 
 | ||||||
| class Notification < ApplicationRecord | class Notification < ApplicationRecord | ||||||
|  |  | ||||||
|  | @ -24,43 +24,44 @@ class FetchAtomService < BaseService | ||||||
| 
 | 
 | ||||||
|   def process(url, terminal = false) |   def process(url, terminal = false) | ||||||
|     @url = url |     @url = url | ||||||
|     perform_request |     perform_request { |response| process_response(response, terminal) } | ||||||
|     process_response(terminal) |  | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def perform_request |   def perform_request(&block) | ||||||
|     accept = 'text/html' |     accept = 'text/html' | ||||||
|     accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity |     accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity | ||||||
| 
 | 
 | ||||||
|     @response = Request.new(:get, @url) |     Request.new(:get, @url).add_headers('Accept' => accept).perform(&block) | ||||||
|                        .add_headers('Accept' => accept) |  | ||||||
|                        .perform |  | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def process_response(terminal = false) |   def process_response(response, terminal = false) | ||||||
|     return nil if @response.code != 200 |     return nil if response.code != 200 | ||||||
| 
 | 
 | ||||||
|     if @response.mime_type == 'application/atom+xml' |     if response.mime_type == 'application/atom+xml' | ||||||
|       [@url, { prefetched_body: @response.to_s }, :ostatus] |       [@url, { prefetched_body: response.to_s }, :ostatus] | ||||||
|     elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type) |     elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(response.mime_type) | ||||||
|       json = body_to_json(@response.to_s) |       json = body_to_json(response.to_s) | ||||||
|       if supported_context?(json) && json['type'] == 'Person' && json['inbox'].present? |       if supported_context?(json) && json['type'] == 'Person' && json['inbox'].present? | ||||||
|         [json['id'], { prefetched_body: @response.to_s, id: true }, :activitypub] |         [json['id'], { prefetched_body: response.to_s, id: true }, :activitypub] | ||||||
|       elsif supported_context?(json) && json['type'] == 'Note' |       elsif supported_context?(json) && json['type'] == 'Note' | ||||||
|         [json['id'], { prefetched_body: @response.to_s, id: true }, :activitypub] |         [json['id'], { prefetched_body: response.to_s, id: true }, :activitypub] | ||||||
|       else |       else | ||||||
|         @unsupported_activity = true |         @unsupported_activity = true | ||||||
|         nil |         nil | ||||||
|       end |       end | ||||||
|     elsif @response['Link'] && !terminal && link_header.find_link(%w(rel alternate)) |     elsif !terminal | ||||||
|       process_headers |       link_header = response['Link'] && parse_link_header(response) | ||||||
|     elsif @response.mime_type == 'text/html' && !terminal | 
 | ||||||
|       process_html |       if link_header&.find_link(%w(rel alternate)) | ||||||
|  |         process_link_headers(link_header) | ||||||
|  |       elsif response.mime_type == 'text/html' | ||||||
|  |         process_html(response) | ||||||
|  |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def process_html |   def process_html(response) | ||||||
|     page = Nokogiri::HTML(@response.to_s) |     page = Nokogiri::HTML(response.to_s) | ||||||
| 
 | 
 | ||||||
|     json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) } |     json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) } | ||||||
|     atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' } |     atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' } | ||||||
|  | @ -71,7 +72,7 @@ class FetchAtomService < BaseService | ||||||
|     result |     result | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def process_headers |   def process_link_headers(link_header) | ||||||
|     json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']) |     json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']) | ||||||
|     atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml)) |     atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml)) | ||||||
| 
 | 
 | ||||||
|  | @ -81,7 +82,7 @@ class FetchAtomService < BaseService | ||||||
|     result |     result | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def link_header |   def parse_link_header(response) | ||||||
|     @link_header ||= LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link']) |     LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link']) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -36,15 +36,24 @@ class FetchLinkCardService < BaseService | ||||||
| 
 | 
 | ||||||
|   def process_url |   def process_url | ||||||
|     @card ||= PreviewCard.new(url: @url) |     @card ||= PreviewCard.new(url: @url) | ||||||
|     res     = Request.new(:head, @url).perform |  | ||||||
| 
 | 
 | ||||||
|     return if res.code != 405 && (res.code != 200 || res.mime_type != 'text/html') |     failed = Request.new(:head, @url).perform do |res| | ||||||
|  |       res.code != 405 && (res.code != 200 || res.mime_type != 'text/html') | ||||||
|  |     end | ||||||
| 
 | 
 | ||||||
|     @response = Request.new(:get, @url).perform |     return if failed | ||||||
| 
 | 
 | ||||||
|     return if @response.code != 200 || @response.mime_type != 'text/html' |     Request.new(:get, @url).perform do |res| | ||||||
|  |       if res.code == 200 && res.mime_type == 'text/html' | ||||||
|  |         @html = res.to_s | ||||||
|  |         @html_charset = res.charset | ||||||
|  |       else | ||||||
|  |         @html = nil | ||||||
|  |         @html_charset = nil | ||||||
|  |       end | ||||||
|  |     end | ||||||
| 
 | 
 | ||||||
|     @html = @response.to_s |     return if @html.nil? | ||||||
| 
 | 
 | ||||||
|     attempt_oembed || attempt_opengraph |     attempt_oembed || attempt_opengraph | ||||||
|   end |   end | ||||||
|  | @ -118,7 +127,7 @@ class FetchLinkCardService < BaseService | ||||||
|     detector = CharlockHolmes::EncodingDetector.new |     detector = CharlockHolmes::EncodingDetector.new | ||||||
|     detector.strip_tags = true |     detector.strip_tags = true | ||||||
| 
 | 
 | ||||||
|     guess = detector.detect(@html, @response.charset) |     guess = detector.detect(@html, @html_charset) | ||||||
|     page  = Nokogiri::HTML(@html, nil, guess&.fetch(:encoding, nil)) |     page  = Nokogiri::HTML(@html, nil, guess&.fetch(:encoding, nil)) | ||||||
| 
 | 
 | ||||||
|     if meta_property(page, 'twitter:player') |     if meta_property(page, 'twitter:player') | ||||||
|  |  | ||||||
|  | @ -179,11 +179,10 @@ class ResolveAccountService < BaseService | ||||||
|   def atom_body |   def atom_body | ||||||
|     return @atom_body if defined?(@atom_body) |     return @atom_body if defined?(@atom_body) | ||||||
| 
 | 
 | ||||||
|     response = Request.new(:get, atom_url).perform |     @atom_body = Request.new(:get, atom_url).perform do |response| | ||||||
| 
 |       raise Mastodon::UnexpectedResponseError, response unless response.code == 200 | ||||||
|     raise Mastodon::UnexpectedResponseError, response unless response.code == 200 |       response.to_s | ||||||
| 
 |     end | ||||||
|     @atom_body = response.to_s |  | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def actor_json |   def actor_json | ||||||
|  |  | ||||||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
		Reference in a new issue