about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorThibG <thib@sitedethib.com>2019-11-07 13:41:56 +0100
committerGitHub <noreply@github.com>2019-11-07 13:41:56 +0100
commitdd2ec970dbe39f09d3da32d6a8f524aaad68a9d6 (patch)
tree0d3596a944cd4ddc27c8c3989f039f640730f70c /app
parent00793a86bc52e4cb37318e42ea4ceb37a896c727 (diff)
parentf63dad52943c757489975e0c39e771b6bc96e5ba (diff)
Merge pull request #1242 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
Diffstat (limited to 'app')
-rw-r--r--app/javascript/flavours/glitch/components/modal_root.js1
-rw-r--r--app/javascript/flavours/glitch/components/poll.js3
-rw-r--r--app/javascript/flavours/glitch/components/status_prepend.js22
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js10
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/status_list_container.js7
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js4
-rw-r--r--app/javascript/flavours/glitch/packs/public.js10
-rw-r--r--app/javascript/flavours/glitch/packs/settings.js10
-rw-r--r--app/javascript/flavours/glitch/util/load_keyboard_extensions.js16
-rw-r--r--app/javascript/mastodon/actions/importer/normalizer.js6
-rw-r--r--app/javascript/mastodon/actions/notifications.js3
-rw-r--r--app/javascript/mastodon/components/modal_root.js1
-rw-r--r--app/javascript/mastodon/components/poll.js3
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js43
-rw-r--r--app/javascript/mastodon/features/ui/components/focal_point_modal.js10
-rw-r--r--app/javascript/mastodon/features/ui/containers/status_list_container.js7
-rw-r--r--app/javascript/mastodon/features/ui/index.js4
-rw-r--r--app/javascript/mastodon/load_keyboard_extensions.js16
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json20
-rw-r--r--app/javascript/mastodon/locales/en.json2
-rw-r--r--app/javascript/packs/public.js10
-rw-r--r--app/lib/activitypub/activity.rb2
-rw-r--r--app/models/account.rb4
-rw-r--r--app/models/list_account.rb6
-rw-r--r--app/models/media_attachment.rb6
-rw-r--r--app/services/account_search_service.rb2
-rw-r--r--app/views/public_timelines/show.html.haml6
27 files changed, 179 insertions, 55 deletions
diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js
index fd0af9f6e..e73ef8d12 100644
--- a/app/javascript/flavours/glitch/components/modal_root.js
+++ b/app/javascript/flavours/glitch/components/modal_root.js
@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import 'wicg-inert';
 import createHistory from 'history/createBrowserHistory';
 
 export default class ModalRoot extends React.PureComponent {
diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js
index a7f9a97da..2d2a7cbe0 100644
--- a/app/javascript/flavours/glitch/components/poll.js
+++ b/app/javascript/flavours/glitch/components/poll.js
@@ -39,7 +39,8 @@ class Poll extends ImmutablePureComponent {
 
   static getDerivedStateFromProps (props, state) {
     const { poll, intl } = props;
-    const expired = poll.get('expired') || (new Date(poll.get('expires_at'))).getTime() < intl.now();
+    const expires_at = poll.get('expires_at');
+    const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now();
     return (expired === state.expired) ? null : { expired };
   }
 
diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js
index 344c4d423..637c4f23a 100644
--- a/app/javascript/flavours/glitch/components/status_prepend.js
+++ b/app/javascript/flavours/glitch/components/status_prepend.js
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { FormattedMessage } from 'react-intl';
 import Icon from 'flavours/glitch/components/icon';
+import { me } from 'flavours/glitch/util/initial_state';
 
 export default class StatusPrepend extends React.PureComponent {
 
@@ -64,12 +65,21 @@ export default class StatusPrepend extends React.PureComponent {
         />
       );
     case 'poll':
-      return (
-        <FormattedMessage
-          id='notification.poll'
-          defaultMessage='A poll you have voted in has ended'
-        />
-      );
+      if (me === account.get('id')) {
+        return (
+          <FormattedMessage
+            id='notification.own_poll'
+            defaultMessage='Your poll has ended'
+          />
+        );
+      } else {
+        return (
+          <FormattedMessage
+            id='notification.poll'
+            defaultMessage='A poll you have voted in has ended'
+          />
+        );
+      }
     }
     return null;
   }
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index f5ecf77b9..70e86905f 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -184,6 +184,15 @@ class FocalPointModal extends ImmutablePureComponent {
     this.setState({ description: e.target.value, dirty: true });
   }
 
+  handleKeyDown = (e) => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.setState({ description: e.target.value, dirty: true });
+      this.handleSubmit();
+    }
+  }
+
   handleSubmit = () => {
     this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
     this.props.onClose();
@@ -254,6 +263,7 @@ class FocalPointModal extends ImmutablePureComponent {
                 className='setting-text light'
                 value={detecting ? '…' : description}
                 onChange={this.handleChange}
+                onKeyDown={this.handleKeyDown}
                 disabled={detecting}
                 autoFocus
               />
diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
index 4ca853563..c01d0e5bc 100644
--- a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
+++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
@@ -19,9 +19,9 @@ const getRegex = createSelector([
   return regex;
 });
 
-const makeGetStatusIds = () => createSelector([
+const makeGetStatusIds = (pending = false) => createSelector([
   (state, { type }) => state.getIn(['settings', type], ImmutableMap()),
-  (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
+  (state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()),
   (state)           => state.get('statuses'),
   getRegex,
 ], (columnSettings, statusIds, statuses, regex) => {
@@ -56,13 +56,14 @@ const makeGetStatusIds = () => createSelector([
 
 const makeMapStateToProps = () => {
   const getStatusIds = makeGetStatusIds();
+  const getPendingStatusIds = makeGetStatusIds(true);
 
   const mapStateToProps = (state, { timelineId }) => ({
     statusIds: getStatusIds(state, { type: timelineId }),
     isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
     isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
     hasMore:   state.getIn(['timelines', timelineId, 'hasMore']),
-    numPending: state.getIn(['timelines', timelineId, 'pendingItems'], ImmutableList()).size,
+    numPending: getPendingStatusIds(state, { type: timelineId }).size,
   });
 
   return mapStateToProps;
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index e5925a484..646def8f2 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -174,7 +174,9 @@ class SwitchingColumnsArea extends React.PureComponent {
   }
 
   setRef = c => {
-    this.node = c.getWrappedInstance();
+    if (c) {
+      this.node = c.getWrappedInstance();
+    }
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
index 767fb023c..973d6ee46 100644
--- a/app/javascript/flavours/glitch/packs/public.js
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -1,5 +1,6 @@
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 import ready from 'flavours/glitch/util/ready';
+import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';
 
 function main() {
   const IntlMessageFormat = require('intl-messageformat').default;
@@ -118,6 +119,9 @@ function main() {
   });
 }
 
-loadPolyfills().then(main).catch(error => {
-  console.error(error);
-});
+loadPolyfills()
+  .then(main)
+  .then(loadKeyboardExtensions)
+  .catch(error => {
+    console.error(error);
+  });
diff --git a/app/javascript/flavours/glitch/packs/settings.js b/app/javascript/flavours/glitch/packs/settings.js
index b32f38226..edf1b82e0 100644
--- a/app/javascript/flavours/glitch/packs/settings.js
+++ b/app/javascript/flavours/glitch/packs/settings.js
@@ -1,5 +1,6 @@
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 import ready from 'flavours/glitch/util/ready';
+import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';
 
 function main() {
   const { delegate } = require('rails-ujs');
@@ -15,6 +16,9 @@ function main() {
   });
 }
 
-loadPolyfills().then(main).catch(error => {
-  console.error(error);
-});
+loadPolyfills()
+  .then(main)
+  .then(loadKeyboardExtensions)
+  .catch(error => {
+    console.error(error);
+  });
diff --git a/app/javascript/flavours/glitch/util/load_keyboard_extensions.js b/app/javascript/flavours/glitch/util/load_keyboard_extensions.js
new file mode 100644
index 000000000..2dd0e45fa
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/load_keyboard_extensions.js
@@ -0,0 +1,16 @@
+// On KaiOS, we may not be able to use a mouse cursor or navigate using Tab-based focus, so we install
+// special left/right focus navigation keyboard listeners, at least on public pages (i.e. so folks
+// can at least log in using KaiOS devices).
+
+function importArrowKeyNavigation() {
+  return import(/* webpackChunkName: "arrow-key-navigation" */ 'arrow-key-navigation');
+}
+
+export default function loadKeyboardExtensions() {
+  if (/KAIOS/.test(navigator.userAgent)) {
+    return importArrowKeyNavigation().then(arrowKeyNav => {
+      arrowKeyNav.register();
+    });
+  }
+  return Promise.resolve();
+}
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index f7108fdb9..78f321da4 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -10,6 +10,12 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
   return obj;
 }, {});
 
+export function searchTextFromRawStatus (status) {
+  const spoilerText   = status.spoiler_text || '';
+  const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+  return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+}
+
 export function normalizeAccount(account) {
   account = { ...account };
 
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 58803d1ae..3a92e0224 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -14,6 +14,7 @@ import { unescapeHTML } from '../utils/html';
 import { getFiltersRegex } from '../selectors';
 import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
 import compareId from 'mastodon/compare_id';
+import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
 
 export const NOTIFICATIONS_UPDATE      = 'NOTIFICATIONS_UPDATE';
 export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@@ -60,7 +61,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
     if (notification.type === 'mention') {
       const dropRegex   = filters[0];
       const regex       = filters[1];
-      const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
+      const searchIndex = searchTextFromRawStatus(notification.status);
 
       if (dropRegex && dropRegex.test(searchIndex)) {
         return;
diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js
index 5d4f4bbe1..c55fa0f74 100644
--- a/app/javascript/mastodon/components/modal_root.js
+++ b/app/javascript/mastodon/components/modal_root.js
@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import 'wicg-inert';
 
 export default class ModalRoot extends React.PureComponent {
 
diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js
index cdbcf8f70..0edd064e0 100644
--- a/app/javascript/mastodon/components/poll.js
+++ b/app/javascript/mastodon/components/poll.js
@@ -39,7 +39,8 @@ class Poll extends ImmutablePureComponent {
 
   static getDerivedStateFromProps (props, state) {
     const { poll, intl } = props;
-    const expired = poll.get('expired') || (new Date(poll.get('expires_at'))).getTime() < intl.now();
+    const expires_at = poll.get('expires_at');
+    const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now();
     return (expired === state.expired) ? null : { expired };
   }
 
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index 41e9324e6..2dea8afa7 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -1,13 +1,22 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import StatusContainer from '../../../containers/status_container';
-import AccountContainer from '../../../containers/account_container';
-import { injectIntl, FormattedMessage } from 'react-intl';
-import Permalink from '../../../components/permalink';
-import ImmutablePureComponent from 'react-immutable-pure-component';
+import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
 import { HotKeys } from 'react-hotkeys';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from 'mastodon/initial_state';
+import StatusContainer from 'mastodon/containers/status_container';
+import AccountContainer from 'mastodon/containers/account_container';
 import Icon from 'mastodon/components/icon';
+import Permalink from 'mastodon/components/permalink';
+
+const messages = defineMessages({
+  favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
+  follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
+  ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
+  poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
+  reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
+});
 
 const notificationForScreenReader = (intl, message, timestamp) => {
   const output = [message];
@@ -107,7 +116,7 @@ class Notification extends ImmutablePureComponent {
 
     return (
       <HotKeys handlers={this.getHandlers()}>
-        <div className='notification notification-follow focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow', defaultMessage: '{name} followed you' }, { name: account.get('acct') }), notification.get('created_at'))}>
+        <div className='notification notification-follow focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}>
           <div className='notification__message'>
             <div className='notification__favourite-icon-wrapper'>
               <Icon id='user-plus' fixedWidth />
@@ -146,7 +155,7 @@ class Notification extends ImmutablePureComponent {
 
     return (
       <HotKeys handlers={this.getHandlers()}>
-        <div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.favourite', defaultMessage: '{name} favourited your status' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
+        <div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
           <div className='notification__message'>
             <div className='notification__favourite-icon-wrapper'>
               <Icon id='star' className='star-icon' fixedWidth />
@@ -178,7 +187,7 @@ class Notification extends ImmutablePureComponent {
 
     return (
       <HotKeys handlers={this.getHandlers()}>
-        <div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
+        <div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
           <div className='notification__message'>
             <div className='notification__favourite-icon-wrapper'>
               <Icon id='retweet' fixedWidth />
@@ -205,25 +214,31 @@ class Notification extends ImmutablePureComponent {
     );
   }
 
-  renderPoll (notification) {
+  renderPoll (notification, account) {
     const { intl } = this.props;
+    const ownPoll  = me === account.get('id');
+    const message  = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll);
 
     return (
       <HotKeys handlers={this.getHandlers()}>
-        <div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }), notification.get('created_at'))}>
+        <div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
           <div className='notification__message'>
             <div className='notification__favourite-icon-wrapper'>
               <Icon id='tasks' fixedWidth />
             </div>
 
             <span title={notification.get('created_at')}>
-              <FormattedMessage id='notification.poll' defaultMessage='A poll you have voted in has ended' />
+              {ownPoll ? (
+                <FormattedMessage id='notification.own_poll' defaultMessage='Your poll has ended' />
+              ) : (
+                <FormattedMessage id='notification.poll' defaultMessage='A poll you have voted in has ended' />
+              )}
             </span>
           </div>
 
           <StatusContainer
             id={notification.get('status')}
-            account={notification.get('account')}
+            account={account}
             muted
             withDismiss
             hidden={this.props.hidden}
@@ -253,7 +268,7 @@ class Notification extends ImmutablePureComponent {
     case 'reblog':
       return this.renderReblog(notification, link);
     case 'poll':
-      return this.renderPoll(notification);
+      return this.renderPoll(notification, account);
     }
 
     return null;
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
index 3694ab904..bbd463fca 100644
--- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
@@ -184,6 +184,15 @@ class FocalPointModal extends ImmutablePureComponent {
     this.setState({ description: e.target.value, dirty: true });
   }
 
+  handleKeyDown = (e) => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.setState({ description: e.target.value, dirty: true });
+      this.handleSubmit();
+    }
+  }
+
   handleSubmit = () => {
     this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
     this.props.onClose();
@@ -254,6 +263,7 @@ class FocalPointModal extends ImmutablePureComponent {
                 className='setting-text light'
                 value={detecting ? '…' : description}
                 onChange={this.handleChange}
+                onKeyDown={this.handleKeyDown}
                 disabled={detecting}
                 autoFocus
               />
diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js
index 7b8eb652b..9f6cbf988 100644
--- a/app/javascript/mastodon/features/ui/containers/status_list_container.js
+++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js
@@ -6,9 +6,9 @@ import { createSelector } from 'reselect';
 import { debounce } from 'lodash';
 import { me } from '../../../initial_state';
 
-const makeGetStatusIds = () => createSelector([
+const makeGetStatusIds = (pending = false) => createSelector([
   (state, { type }) => state.getIn(['settings', type], ImmutableMap()),
-  (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
+  (state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()),
   (state)           => state.get('statuses'),
 ], (columnSettings, statusIds, statuses) => {
   return statusIds.filter(id => {
@@ -31,13 +31,14 @@ const makeGetStatusIds = () => createSelector([
 
 const makeMapStateToProps = () => {
   const getStatusIds = makeGetStatusIds();
+  const getPendingStatusIds = makeGetStatusIds(true);
 
   const mapStateToProps = (state, { timelineId }) => ({
     statusIds: getStatusIds(state, { type: timelineId }),
     isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
     isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
     hasMore:   state.getIn(['timelines', timelineId, 'hasMore']),
-    numPending: state.getIn(['timelines', timelineId, 'pendingItems'], ImmutableList()).size,
+    numPending: getPendingStatusIds(state, { type: timelineId }).size,
   });
 
   return mapStateToProps;
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 791ff9a2e..a45ba91eb 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -164,7 +164,9 @@ class SwitchingColumnsArea extends React.PureComponent {
   }
 
   setRef = c => {
-    this.node = c.getWrappedInstance();
+    if (c) {
+      this.node = c.getWrappedInstance();
+    }
   }
 
   render () {
diff --git a/app/javascript/mastodon/load_keyboard_extensions.js b/app/javascript/mastodon/load_keyboard_extensions.js
new file mode 100644
index 000000000..2dd0e45fa
--- /dev/null
+++ b/app/javascript/mastodon/load_keyboard_extensions.js
@@ -0,0 +1,16 @@
+// On KaiOS, we may not be able to use a mouse cursor or navigate using Tab-based focus, so we install
+// special left/right focus navigation keyboard listeners, at least on public pages (i.e. so folks
+// can at least log in using KaiOS devices).
+
+function importArrowKeyNavigation() {
+  return import(/* webpackChunkName: "arrow-key-navigation" */ 'arrow-key-navigation');
+}
+
+export default function loadKeyboardExtensions() {
+  if (/KAIOS/.test(navigator.userAgent)) {
+    return importArrowKeyNavigation().then(arrowKeyNav => {
+      arrowKeyNav.register();
+    });
+  }
+  return Promise.resolve();
+}
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index cf9a38757..a65481998 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -2079,20 +2079,24 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "{name} followed you",
-        "id": "notification.follow"
-      },
-      {
         "defaultMessage": "{name} favourited your status",
         "id": "notification.favourite"
       },
       {
-        "defaultMessage": "{name} boosted your status",
-        "id": "notification.reblog"
+        "defaultMessage": "{name} followed you",
+        "id": "notification.follow"
+      },
+      {
+        "defaultMessage": "Your poll has ended",
+        "id": "notification.own_poll"
       },
       {
         "defaultMessage": "A poll you have voted in has ended",
         "id": "notification.poll"
+      },
+      {
+        "defaultMessage": "{name} boosted your status",
+        "id": "notification.reblog"
       }
     ],
     "path": "app/javascript/mastodon/features/notifications/components/notification.json"
@@ -2772,6 +2776,10 @@
         "id": "video.exit_fullscreen"
       },
       {
+        "defaultMessage": "Download file",
+        "id": "video.download"
+      },
+      {
         "defaultMessage": "Sensitive content",
         "id": "status.sensitive_warning"
       },
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 4005a54c3..38f190791 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -279,6 +279,7 @@
   "notification.favourite": "{name} favourited your status",
   "notification.follow": "{name} followed you",
   "notification.mention": "{name} mentioned you",
+  "notification.own_poll": "Your poll has ended",
   "notification.poll": "A poll you have voted in has ended",
   "notification.reblog": "{name} boosted your status",
   "notifications.clear": "Clear notifications",
@@ -417,6 +418,7 @@
   "upload_modal.preview_label": "Preview ({ratio})",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
+  "video.download": "Download file",
   "video.exit_fullscreen": "Exit full screen",
   "video.expand": "Expand video",
   "video.fullscreen": "Full screen",
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 1229a7505..3eae1a457 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -1,6 +1,7 @@
 import loadPolyfills from '../mastodon/load_polyfills';
 import ready from '../mastodon/ready';
 import { start } from '../mastodon/common';
+import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
 
 start();
 
@@ -122,6 +123,9 @@ function main() {
   });
 }
 
-loadPolyfills().then(main).catch(error => {
-  console.error(error);
-});
+loadPolyfills()
+  .then(main)
+  .then(loadKeyboardExtensions)
+  .catch(error => {
+    console.error(error);
+  });
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index 5cd48a6ae..cdd406043 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -158,7 +158,7 @@ class ActivityPub::Activity
   end
 
   def follow_from_object
-    @follow ||= Follow.find_by(target_account: @account, uri: object_uri) unless object_uri.nil?
+    @follow ||= ::Follow.find_by(target_account: @account, uri: object_uri) unless object_uri.nil?
   end
 
   def fetch_remote_original_status
diff --git a/app/models/account.rb b/app/models/account.rb
index db2eb8993..648378f7b 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -431,6 +431,8 @@ class Account < ApplicationRecord
             SELECT target_account_id
             FROM follows
             WHERE account_id = ?
+            UNION ALL
+            SELECT ?
           )
           SELECT
             accounts.*,
@@ -446,7 +448,7 @@ class Account < ApplicationRecord
           LIMIT ? OFFSET ?
         SQL
 
-        records = find_by_sql([sql, account.id, account.id, account.id, limit, offset])
+        records = find_by_sql([sql, account.id, account.id, account.id, account.id, limit, offset])
       else
         sql = <<-SQL.squish
           SELECT
diff --git a/app/models/list_account.rb b/app/models/list_account.rb
index 87b498224..785923c4c 100644
--- a/app/models/list_account.rb
+++ b/app/models/list_account.rb
@@ -6,13 +6,13 @@
 #  id         :bigint(8)        not null, primary key
 #  list_id    :bigint(8)        not null
 #  account_id :bigint(8)        not null
-#  follow_id  :bigint(8)        not null
+#  follow_id  :bigint(8)
 #
 
 class ListAccount < ApplicationRecord
   belongs_to :list
   belongs_to :account
-  belongs_to :follow
+  belongs_to :follow, optional: true
 
   validates :account_id, uniqueness: { scope: :list_id }
 
@@ -21,6 +21,6 @@ class ListAccount < ApplicationRecord
   private
 
   def set_follow
-    self.follow = Follow.find_by(account_id: list.account_id, target_account_id: account.id)
+    self.follow = Follow.find_by!(account_id: list.account_id, target_account_id: account.id) unless list.account_id == account.id
   end
 end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index b5e65212e..9bba89e57 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -26,6 +26,8 @@ class MediaAttachment < ApplicationRecord
 
   enum type: [:image, :gifv, :video, :unknown, :audio]
 
+  MAX_DESCRIPTION_LENGTH = 1_500
+
   IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze
   VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
   AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp .wma).freeze
@@ -139,7 +141,7 @@ class MediaAttachment < ApplicationRecord
   include Attachmentable
 
   validates :account, presence: true
-  validates :description, length: { maximum: 1_500 }, if: :local?
+  validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }, if: :local?
 
   scope :attached,   -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
   scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
@@ -243,7 +245,7 @@ class MediaAttachment < ApplicationRecord
   end
 
   def prepare_description
-    self.description = description.strip[0...420] unless description.nil?
+    self.description = description.strip[0...MAX_DESCRIPTION_LENGTH] unless description.nil?
   end
 
   def set_type_and_extension
diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb
index 40c5f8590..7e74cc893 100644
--- a/app/services/account_search_service.rb
+++ b/app/services/account_search_service.rb
@@ -127,7 +127,7 @@ class AccountSearchService < BaseService
   end
 
   def following_ids
-    @following_ids ||= account.active_relationships.pluck(:target_account_id)
+    @following_ids ||= account.active_relationships.pluck(:target_account_id) + [account.id]
   end
 
   def limit_for_non_exact_results
diff --git a/app/views/public_timelines/show.html.haml b/app/views/public_timelines/show.html.haml
index 591620f64..063089a7f 100644
--- a/app/views/public_timelines/show.html.haml
+++ b/app/views/public_timelines/show.html.haml
@@ -6,7 +6,11 @@
 
 .page-header
   %h1= t('about.see_whats_happening')
-  %p= t('about.browse_public_posts')
+
+  - if Setting.show_known_fediverse_at_about_page
+    %p= t('about.browse_public_posts')
+  - else
+    %p= t('about.browse_local_posts')
 
 #mastodon-timeline{ data: { props: Oj.dump(default_props) }}
 #modal-container