From 2a90da18375a38957ae4c94fa3e86a8180237d8a Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 27 Mar 2018 04:33:57 +0200 Subject: Fix UniqueUsernameValidator comparison (#6926) Comparison was downcasing only one side, therefore if previously existing account had a non-lowercase spelling, it would be ignored when checking for duplicates. New rake task `mastodon:maintenance:find_duplicate_usernames` will help find constraint violations that might have occured from the presence of this bug. Bump version to 2.3.3 --- app/models/concerns/account_finder_concern.rb | 2 +- app/validators/unique_username_validator.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'app') diff --git a/app/models/concerns/account_finder_concern.rb b/app/models/concerns/account_finder_concern.rb index 2e8a7fb37..6b7237e89 100644 --- a/app/models/concerns/account_finder_concern.rb +++ b/app/models/concerns/account_finder_concern.rb @@ -30,7 +30,7 @@ module AccountFinderConcern end def account - scoped_accounts.take + scoped_accounts.order(id: :asc).take end private diff --git a/app/validators/unique_username_validator.rb b/app/validators/unique_username_validator.rb index c76407b16..fb67105dd 100644 --- a/app/validators/unique_username_validator.rb +++ b/app/validators/unique_username_validator.rb @@ -6,7 +6,7 @@ class UniqueUsernameValidator < ActiveModel::Validator normalized_username = account.username.downcase.delete('.') - scope = Account.where(domain: nil, username: normalized_username) + scope = Account.where(domain: nil).where('lower(username) = ?', normalized_username) scope = scope.where.not(id: account.id) if account.persisted? account.errors.add(:username, :taken) if scope.exists? -- cgit From 31e7b7308489ecc8b43f83b78ec0a288c4195d5b Mon Sep 17 00:00:00 2001 From: Yuto Tokunaga Date: Tue, 27 Mar 2018 19:30:28 +0900 Subject: fix #6846 (#6914) --- app/javascript/styles/mastodon/components.scss | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) (limited to 'app') diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 1fb1fa851..2b13b80a7 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -3436,6 +3436,19 @@ a.status-card { width: 100%; height: 100%; position: relative; + + .extended-video-player { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + + video { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + } + } } .media-modal__closer { @@ -4411,8 +4424,8 @@ a.status-card { border-radius: 4px; video { - height: 100%; - width: 100%; + max-width: 100vw; + max-height: 80vh; z-index: 1; } -- cgit From ca42f9b0ebfa1f4e8e86745a79af138b5865daee Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Tue, 27 Mar 2018 19:32:30 +0900 Subject: Cache media (#6902) --- app/javascript/mastodon/actions/accounts.js | 2 +- app/javascript/mastodon/actions/importer/index.js | 2 +- app/javascript/mastodon/actions/statuses.js | 4 +- app/javascript/mastodon/db/async.js | 28 ---- app/javascript/mastodon/db/modifier.js | 93 ------------- app/javascript/mastodon/service_worker/entry.js | 30 ++++- app/javascript/mastodon/storage/db.js | 28 ++++ app/javascript/mastodon/storage/modifier.js | 151 ++++++++++++++++++++++ config/webpack/production.js | 2 +- package.json | 1 + yarn.lock | 7 + 11 files changed, 217 insertions(+), 131 deletions(-) delete mode 100644 app/javascript/mastodon/db/async.js delete mode 100644 app/javascript/mastodon/db/modifier.js create mode 100644 app/javascript/mastodon/storage/db.js create mode 100644 app/javascript/mastodon/storage/modifier.js (limited to 'app') diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 1d1947aca..7cacff909 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -1,5 +1,5 @@ import api, { getLinks } from '../api'; -import asyncDB from '../db/async'; +import asyncDB from '../storage/db'; import { importAccount, importFetchedAccount, importFetchedAccounts } from './importer'; export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index a97f4d173..e671d417c 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -1,4 +1,4 @@ -import { putAccounts, putStatuses } from '../../db/modifier'; +import { putAccounts, putStatuses } from '../../storage/modifier'; import { normalizeAccount, normalizeStatus } from './normalizer'; export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index dcd813dd9..d28aef880 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -1,6 +1,6 @@ import api from '../api'; -import asyncDB from '../db/async'; -import { evictStatus } from '../db/modifier'; +import asyncDB from '../storage/db'; +import { evictStatus } from '../storage/modifier'; import { deleteFromTimelines } from './timelines'; import { fetchStatusCard } from './cards'; diff --git a/app/javascript/mastodon/db/async.js b/app/javascript/mastodon/db/async.js deleted file mode 100644 index e08fc3f3d..000000000 --- a/app/javascript/mastodon/db/async.js +++ /dev/null @@ -1,28 +0,0 @@ -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'); - }; -}); diff --git a/app/javascript/mastodon/db/modifier.js b/app/javascript/mastodon/db/modifier.js deleted file mode 100644 index eb951905a..000000000 --- a/app/javascript/mastodon/db/modifier.js +++ /dev/null @@ -1,93 +0,0 @@ -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); -} diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js index 8b65f27a3..b9cf06ef9 100644 --- a/app/javascript/mastodon/service_worker/entry.js +++ b/app/javascript/mastodon/service_worker/entry.js @@ -1,6 +1,10 @@ import './web_push_notifications'; -function openCache() { +function openSystemCache() { + return caches.open('mastodon-system'); +} + +function openWebCache() { return caches.open('mastodon-web'); } @@ -11,7 +15,7 @@ function fetchRoot() { // Cause a new version of a registered Service Worker to replace an existing one // that is already installed, and replace the currently active worker on open pages. self.addEventListener('install', function(event) { - event.waitUntil(Promise.all([openCache(), fetchRoot()]).then(([cache, root]) => cache.put('/', root))); + event.waitUntil(Promise.all([openWebCache(), fetchRoot()]).then(([cache, root]) => cache.put('/', root))); }); self.addEventListener('activate', function(event) { event.waitUntil(self.clients.claim()); @@ -21,7 +25,7 @@ self.addEventListener('fetch', function(event) { if (url.pathname.startsWith('/web/')) { const asyncResponse = fetchRoot(); - const asyncCache = openCache(); + const asyncCache = openWebCache(); event.respondWith(asyncResponse.then(async response => { if (response.ok) { @@ -31,10 +35,10 @@ self.addEventListener('fetch', function(event) { } throw null; - }).catch(() => caches.match('/'))); + }).catch(() => asyncCache.then(cache => cache.match('/')))); } else if (url.pathname === '/auth/sign_out') { const asyncResponse = fetch(event.request); - const asyncCache = openCache(); + const asyncCache = openWebCache(); event.respondWith(asyncResponse.then(async response => { if (response.ok || response.type === 'opaqueredirect') { @@ -44,5 +48,21 @@ self.addEventListener('fetch', function(event) { return response; })); + } else if (process.env.CDN_HOST ? url.host === process.env.CDN_HOST : url.pathname.startsWith('/system/')) { + event.respondWith(openSystemCache().then(async cache => { + const cached = await cache.match(event.request.url); + + if (cached === undefined) { + const fetched = await fetch(event.request); + + if (fetched.ok) { + await cache.put(event.request.url, fetched); + } + + return fetched.clone(); + } + + return cached; + })); } }); diff --git a/app/javascript/mastodon/storage/db.js b/app/javascript/mastodon/storage/db.js new file mode 100644 index 000000000..e08fc3f3d --- /dev/null +++ b/app/javascript/mastodon/storage/db.js @@ -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'); + }; +}); diff --git a/app/javascript/mastodon/storage/modifier.js b/app/javascript/mastodon/storage/modifier.js new file mode 100644 index 000000000..63e49fe6e --- /dev/null +++ b/app/javascript/mastodon/storage/modifier.js @@ -0,0 +1,151 @@ +import asyncDB from './db'; +import { autoPlayGif } from '../initial_state'; + +const accountAssetKeys = ['avatar', 'avatar_static', 'header', 'header_static']; +const avatarKey = autoPlayGif ? 'avatar' : 'avatar_static'; +const limit = 1024; +const asyncCache = caches.open('mastodon-system'); + +function put(name, objects, onupdate, oncreate) { + return asyncDB.then(db => new Promise((resolve, reject) => { + const putTransaction = db.transaction(name, 'readwrite'); + const putStore = putTransaction.objectStore(name); + const putIndex = putStore.index('id'); + + objects.forEach(object => { + putIndex.getKey(object.id).onsuccess = retrieval => { + function addObject() { + putStore.add(object); + } + + function deleteObject() { + putStore.delete(retrieval.target.result).onsuccess = addObject; + } + + if (retrieval.target.result) { + if (onupdate) { + onupdate(object, retrieval.target.result, putStore, deleteObject); + } else { + deleteObject(); + } + } else { + if (oncreate) { + oncreate(object, addObject); + } else { + addObject(); + } + } + }; + }); + + putTransaction.oncomplete = () => { + const readTransaction = db.transaction(name, 'readonly'); + const readStore = readTransaction.objectStore(name); + const count = readStore.count(); + + count.onsuccess = () => { + const excess = count.result - limit; + + if (excess > 0) { + const retrieval = readStore.getAll(null, excess); + + retrieval.onsuccess = () => resolve(retrieval.result); + retrieval.onerror = reject; + } else { + resolve([]); + } + }; + + count.onerror = reject; + }; + + putTransaction.onerror = reject; + })); +} + +function evictAccountsByRecords(records) { + 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(record => { + asyncCache.then(cache => accountAssetKeys.forEach(key => cache.delete(records[key]))); + + accountsMovedIndex.getAll(record.id).onsuccess = ({ target }) => evict(target.result); + + statusesIndex.getAll(record.id).onsuccess = + ({ target }) => evictStatusesByRecords(target.result); + + accountsIdIndex.getKey(record.id).onsuccess = + ({ target }) => target.result && accounts.delete(target.result); + }); + } + + evict(records); + }); +} + +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); + }); + }); +} + +function evictStatusesByRecords(records) { + evictStatuses(records.map(({ id }) => id)); +} + +export function putAccounts(records) { + const newURLs = []; + + put('accounts', records, (newRecord, oldKey, store, oncomplete) => { + store.get(oldKey).onsuccess = ({ target }) => { + accountAssetKeys.forEach(key => { + const newURL = newRecord[key]; + const oldURL = target.result[key]; + + if (newURL !== oldURL) { + asyncCache.then(cache => cache.delete(oldURL)); + } + }); + + const newURL = newRecord[avatarKey]; + const oldURL = target.result[avatarKey]; + + if (newURL !== oldURL) { + newURLs.push(newURL); + } + + oncomplete(); + }; + }, (newRecord, oncomplete) => { + newURLs.push(newRecord[avatarKey]); + oncomplete(); + }).then(records => { + evictAccountsByRecords(records); + asyncCache.then(cache => cache.addAll(newURLs)); + }); +} + +export function putStatuses(records) { + put('statuses', records).then(evictStatusesByRecords); +} diff --git a/config/webpack/production.js b/config/webpack/production.js index e2d7f11dc..e1c681232 100644 --- a/config/webpack/production.js +++ b/config/webpack/production.js @@ -90,7 +90,7 @@ module.exports = merge(sharedConfig, { '**/*.woff', ], ServiceWorker: { - entry: path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'), + entry: `imports-loader?process.env=>${encodeURIComponent(JSON.stringify(process.env))}!${encodeURI(path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'))}`, cacheName: 'mastodon', output: '../assets/sw.js', publicPath: '/sw.js', diff --git a/package.json b/package.json index 33853516b..76f665dba 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "glob": "^7.1.1", "http-link-header": "^0.8.0", "immutable": "^3.8.2", + "imports-loader": "^0.8.0", "intersection-observer": "^0.5.0", "intl": "^1.2.5", "intl-messageformat": "^2.2.0", diff --git a/yarn.lock b/yarn.lock index fbce624be..a1dd4c694 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3364,6 +3364,13 @@ import-local@^0.1.1: pkg-dir "^2.0.0" resolve-cwd "^2.0.0" +imports-loader@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/imports-loader/-/imports-loader-0.8.0.tgz#030ea51b8ca05977c40a3abfd9b4088fe0be9a69" + dependencies: + loader-utils "^1.0.2" + source-map "^0.6.1" + imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" -- cgit From 2f3ac14a434c773577771b74292aa313d57db3c9 Mon Sep 17 00:00:00 2001 From: unarist Date: Tue, 27 Mar 2018 20:05:59 +0900 Subject: Add missing null handling in notification reducer (#6930) This patch adds null item (i.e. gap) handling on below functions to avoid TypeError. * `filterNotifications` called on user mute/block * `deleteByStatus` called on status deletion --- app/javascript/mastodon/reducers/notifications.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index f023984b8..1ac7eb706 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -82,7 +82,7 @@ const expandNormalizedNotifications = (state, notifications, next) => { }; const filterNotifications = (state, relationship) => { - return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id)); + return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id)); }; const updateTop = (state, top) => { @@ -94,7 +94,7 @@ const updateTop = (state, top) => { }; const deleteByStatus = (state, statusId) => { - return state.update('items', list => list.filterNot(item => item.get('status') === statusId)); + return state.update('items', list => list.filterNot(item => item !== null && item.get('status') === statusId)); }; export default function notifications(state = initialState, action) { -- cgit From f5ed5f386020a08e8a659f4a6d25d2b875852be8 Mon Sep 17 00:00:00 2001 From: unarist Date: Tue, 27 Mar 2018 22:18:35 +0900 Subject: Clone response before put it to the cache (#6932) `Response.prototype.clone()` must be called before the response used. This fixes an error from ServiceWorker and failing to load image when the image is not cached. --- app/javascript/mastodon/service_worker/entry.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js index b9cf06ef9..160c3fbf2 100644 --- a/app/javascript/mastodon/service_worker/entry.js +++ b/app/javascript/mastodon/service_worker/entry.js @@ -56,10 +56,10 @@ self.addEventListener('fetch', function(event) { const fetched = await fetch(event.request); if (fetched.ok) { - await cache.put(event.request.url, fetched); + await cache.put(event.request.url, fetched.clone()); } - return fetched.clone(); + return fetched; } return cached; -- cgit From 3523aa440ba3f52bf28fe1e9707506d327c4431f Mon Sep 17 00:00:00 2001 From: unarist Date: Tue, 27 Mar 2018 23:53:52 +0900 Subject: Fix LoadMore on account media gallery (#6933) max_id in the fetch request should be a status id, but media attachment id was used. --- app/javascript/mastodon/features/account_gallery/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index 9a40d139c..5f564d3a9 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -67,7 +67,7 @@ export default class AccountGallery extends ImmutablePureComponent { handleScrollToBottom = () => { if (this.props.hasMore) { - this.handleLoadMore(this.props.medias.last().get('id')); + this.handleLoadMore(this.props.medias.last().getIn(['status', 'id'])); } } -- cgit