Sfoglia il codice sorgente

Merge branch 'threecolumn' into 'develop'

Layout refactoring + Three column mode

See merge request pleroma/pleroma-fe!1503
HJ 2 anni fa
parent
commit
0aa334515b
75 ha cambiato i file con 1452 aggiunte e 1371 eliminazioni
  1. 31 14
      src/App.js
  2. 393 446
      src/App.scss
  3. 18 24
      src/App.vue
  4. 3 3
      src/boot/after_store.js
  5. 1 1
      src/components/about/about.vue
  6. 0 4
      src/components/account_actions/account_actions.vue
  7. 1 1
      src/components/attachment/attachment.scss
  8. 17 80
      src/components/chat/chat.js
  9. 13 75
      src/components/chat/chat.scss
  10. 5 8
      src/components/chat/chat.vue
  11. 9 18
      src/components/chat/chat_layout_utils.js
  12. 1 1
      src/components/chat_list/chat_list.vue
  13. 2 2
      src/components/chat_list_item/chat_list_item.scss
  14. 4 4
      src/components/chat_new/chat_new.scss
  15. 3 4
      src/components/chat_new/chat_new.vue
  16. 11 8
      src/components/chat_title/chat_title.vue
  17. 9 1
      src/components/conversation/conversation.vue
  18. 5 7
      src/components/desktop_nav/desktop_nav.scss
  19. 0 9
      src/components/dialog_modal/dialog_modal.vue
  20. 1 1
      src/components/emoji_input/emoji_input.vue
  21. 5 3
      src/components/emoji_picker/emoji_picker.scss
  22. 5 6
      src/components/global_notice_list/global_notice_list.vue
  23. 1 1
      src/components/link-preview/link-preview.vue
  24. 1 1
      src/components/login_form/login_form.vue
  25. 2 2
      src/components/media_modal/media_modal.vue
  26. 6 2
      src/components/media_upload/media_upload.vue
  27. 2 1
      src/components/mobile_nav/mobile_nav.js
  28. 6 11
      src/components/mobile_nav/mobile_nav.vue
  29. 1 1
      src/components/mobile_post_status_button/mobile_post_status_button.js
  30. 1 1
      src/components/modal/modal.vue
  31. 6 7
      src/components/notifications/notification_filters.vue
  32. 12 2
      src/components/notifications/notifications.js
  33. 10 9
      src/components/notifications/notifications.scss
  34. 59 57
      src/components/notifications/notifications.vue
  35. 7 3
      src/components/password_reset/password_reset.vue
  36. 29 13
      src/components/popover/popover.vue
  37. 1 1
      src/components/post_status_form/post_status_form.js
  38. 26 43
      src/components/post_status_form/post_status_form.vue
  39. 1 1
      src/components/react_button/react_button.vue
  40. 8 5
      src/components/registration/registration.vue
  41. 1 1
      src/components/remote_follow/remote_follow.vue
  42. 3 3
      src/components/select/select.vue
  43. 13 1
      src/components/settings_modal/settings_modal.scss
  44. 7 16
      src/components/settings_modal/settings_modal.vue
  45. 5 0
      src/components/settings_modal/tabs/general_tab.js
  46. 20 0
      src/components/settings_modal/tabs/general_tab.vue
  47. 1 1
      src/components/settings_modal/tabs/mutes_and_blocks_tab.scss
  48. 1 1
      src/components/settings_modal/tabs/profile_tab.scss
  49. 1 18
      src/components/settings_modal/tabs/theme_tab/theme_tab.scss
  50. 10 9
      src/components/shout_panel/shout_panel.vue
  51. 10 7
      src/components/status/status.scss
  52. 1 1
      src/components/status_body/status_body.scss
  53. 4 4
      src/components/still-image/still-image.vue
  54. 2 1
      src/components/tab_switcher/tab_switcher.scss
  55. 5 3
      src/components/timeline/timeline.js
  56. 14 14
      src/components/timeline/timeline.scss
  57. 34 32
      src/components/timeline/timeline.vue
  58. 11 13
      src/components/timeline/timeline_quick_settings.vue
  59. 4 0
      src/components/timeline_menu/timeline_menu.vue
  60. 323 0
      src/components/user_card/user_card.scss
  61. 2 318
      src/components/user_card/user_card.vue
  62. 1 1
      src/components/user_list_popover/user_list_popover.vue
  63. 1 0
      src/components/user_panel/user_panel.vue
  64. 5 1
      src/components/user_profile/user_profile.js
  65. 8 19
      src/components/user_profile/user_profile.vue
  66. 1 12
      src/components/user_reporting_modal/user_reporting_modal.vue
  67. 1 1
      src/hocs/with_load_more/with_load_more.scss
  68. 1 1
      src/hocs/with_subscription/with_subscription.scss
  69. 7 1
      src/i18n/en.json
  70. 6 0
      src/modules/config.js
  71. 24 6
      src/modules/interface.js
  72. 6 0
      src/modules/users.js
  73. 198 0
      src/panel.scss
  74. 1 1
      src/services/offset_finder/offset_finder.service.js
  75. 4 4
      src/services/style_setter/style_setter.js

+ 31 - 14
src/App.js

@@ -1,6 +1,5 @@
 import UserPanel from './components/user_panel/user_panel.vue'
 import NavPanel from './components/nav_panel/nav_panel.vue'
-import Notifications from './components/notifications/notifications.vue'
 import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
 import FeaturesPanel from './components/features_panel/features_panel.vue'
 import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
@@ -16,13 +15,14 @@ import PostStatusModal from './components/post_status_modal/post_status_modal.vu
 import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
 import { windowWidth, windowHeight } from './services/window_utils/window_utils'
 import { mapGetters } from 'vuex'
+import { defineAsyncComponent } from 'vue'
 
 export default {
   name: 'app',
   components: {
     UserPanel,
     NavPanel,
-    Notifications,
+    Notifications: defineAsyncComponent(() => import('./components/notifications/notifications.vue')),
     InstanceSpecificPanel,
     FeaturesPanel,
     WhoToFollowPanel,
@@ -50,6 +50,16 @@ export default {
     window.removeEventListener('resize', this.updateMobileState)
   },
   computed: {
+    classes () {
+      return [
+        {
+          '-reverse': this.reverseLayout,
+          '-no-sticky-headers': this.noSticky,
+          '-has-new-post-button': this.newPostButtonShown
+        },
+        '-' + this.layoutType
+      ]
+    },
     currentUser () { return this.$store.state.users.currentUser },
     userBackground () { return this.currentUser.background_image },
     instanceBackground () {
@@ -72,31 +82,38 @@ export default {
         !this.$store.getters.mergedConfig.hideISP &&
         this.$store.state.instance.instanceSpecificPanelContent
     },
+    isChats () {
+      return this.$route.name === 'chat' || this.$route.name === 'chats'
+    },
+    newPostButtonShown () {
+      if (this.isChats) return false
+      return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
+    },
     showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
     shoutboxPosition () {
-      return this.$store.getters.mergedConfig.showNewPostButton || false
+      return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false
     },
     hideShoutbox () {
       return this.$store.getters.mergedConfig.hideShoutbox
     },
-    isMobileLayout () { return this.$store.state.interface.mobileLayout },
+    layoutType () { return this.$store.state.interface.layoutType },
     privateMode () { return this.$store.state.instance.private },
-    sidebarAlign () {
-      return {
-        'order': this.$store.getters.mergedConfig.sidebarRight ? 99 : 0
+    reverseLayout () {
+      const { thirdColumnMode, sidebarRight: reverseSetting } = this.$store.getters.mergedConfig
+      if (this.layoutType !== 'wide') {
+        return reverseSetting
+      } else {
+        return thirdColumnMode === 'notifications' ? reverseSetting : !reverseSetting
       }
     },
+    noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders },
+    showScrollbars () { return this.$store.getters.mergedConfig.showScrollbars },
     ...mapGetters(['mergedConfig'])
   },
   methods: {
     updateMobileState () {
-      const mobileLayout = windowWidth() <= 800
-      const layoutHeight = windowHeight()
-      const changed = mobileLayout !== this.isMobileLayout
-      if (changed) {
-        this.$store.dispatch('setMobileLayout', mobileLayout)
-      }
-      this.$store.dispatch('setLayoutHeight', layoutHeight)
+      this.$store.dispatch('setLayoutWidth', windowWidth())
+      this.$store.dispatch('setLayoutHeight', windowHeight())
     }
   }
 }

+ 393 - 446
src/App.scss

@@ -1,77 +1,333 @@
+// stylelint-disable rscss/class-format
 @import './_variables.scss';
 
-#app {
-  min-height: 100vh;
-  max-width: 100%;
-  overflow: hidden;
-}
-
-.app-bg-wrapper {
-  position: fixed;
-  z-index: -1;
-  height: 100%;
-  left: 0;
-  right: -20px;
-  background-size: cover;
-  background-repeat: no-repeat;
-  background-color: var(--wallpaper);
-  background-image: var(--body-background-image);
-  background-position: 50% 50px;
-}
-
-i[class^='icon-'] {
-  user-select: none;
-}
-
-h4 {
-  margin: 0;
-}
-
-#content {
-  box-sizing: border-box;
-  padding-top: 60px;
-  margin: auto;
-  min-height: 100vh;
-  max-width: 980px;
-  align-content: flex-start;
-}
-
-.underlay {
-  background-color: rgba(0,0,0,0.15);
-  background-color: var(--underlay, rgba(0,0,0,0.15));
-}
-
-.text-center {
-  text-align: center;
+:root {
+  --navbar-height: 3.5rem;
+  --post-line-height: 1.4;
 }
 
 html {
   font-size: 14px;
+  // overflow-x: clip causes my browser's tab to crash with SIGILL lul
 }
 
 body {
-  overscroll-behavior-y: none;
   font-family: sans-serif;
   font-family: var(--interfaceFont, sans-serif);
   margin: 0;
   color: $fallback--text;
   color: var(--text, $fallback--text);
-  max-width: 100vw;
-  overflow-x: hidden;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
+  overscroll-behavior-y: none;
+  overflow-x: clip;
+  overflow-y: scroll;
 
   &.hidden {
     display: none;
   }
 }
 
+// ## Custom scrollbars
+// Only show custom scrollbars on devices which
+// have a cursor/pointer to operate them
+@media (any-pointer: fine) {
+  * {
+    scrollbar-color: var(--btn) transparent;
+
+    &::-webkit-scrollbar {
+      background: transparent;
+    }
+
+    &::-webkit-scrollbar-button,
+    &::-webkit-scrollbar-thumb {
+      background-color: var(--btn);
+      box-shadow: var(--buttonShadow);
+      border-radius: var(--btnRadius);
+    }
+
+    // horizontal/vertical/increment/decrement are webkit-specific stuff
+    // that indicates whether we're affecting vertical scrollbar, increase button etc
+    // stylelint-disable selector-pseudo-class-no-unknown
+    &::-webkit-scrollbar-button {
+      --___bgPadding: 2px;
+
+      color: var(--btnText);
+      background-repeat: no-repeat, no-repeat;
+
+      &:horizontal {
+        background-size: 50% calc(50% - var(--___bgPadding)), 50% calc(50% - var(--___bgPadding));
+
+        &:increment {
+          background-image:
+            linear-gradient(45deg, var(--btnText) 50%, transparent 51%),
+            linear-gradient(-45deg, transparent 50%, var(--btnText) 51%);
+          background-position: top var(--___bgPadding) left 50%, right 50% bottom var(--___bgPadding);
+        }
+
+        &:decrement {
+          background-image:
+            linear-gradient(45deg, transparent 50%, var(--btnText) 51%),
+            linear-gradient(-45deg, var(--btnText) 50%, transparent 51%);
+          background-position: bottom var(--___bgPadding) right 50%, left 50% top var(--___bgPadding);
+        }
+      }
+
+      &:vertical {
+        background-size: calc(50% - var(--___bgPadding)) 50%, calc(50% - var(--___bgPadding)) 50%;
+
+        &:increment {
+          background-image:
+            linear-gradient(-45deg, transparent 50%, var(--btnText) 51%),
+            linear-gradient(45deg, transparent 50%, var(--btnText) 51%);
+          background-position: right var(--___bgPadding) top 50%, left var(--___bgPadding) top 50%;
+        }
+
+        &:decrement {
+          background-image:
+            linear-gradient(-45deg, var(--btnText) 50%, transparent 51%),
+            linear-gradient(45deg, var(--btnText) 50%, transparent 51%);
+          background-position: left var(--___bgPadding) top 50%, right var(--___bgPadding) top 50%;
+        }
+      }
+    }
+    // stylelint-enable selector-pseudo-class-no-unknown
+  }
+  // Body should have background to scrollbar otherwise it will use white (body color?)
+  html {
+    scrollbar-color: var(--selectedMenu) var(--wallpaper);
+    background: var(--wallpaper);
+  }
+}
+
 a {
   text-decoration: none;
   color: $fallback--link;
   color: var(--link, $fallback--link);
 }
 
+h4 {
+  margin: 0;
+}
+
+i[class*=icon-],
+.svg-inline--fa {
+  color: $fallback--icon;
+  color: var(--icon, $fallback--icon);
+}
+
+nav {
+  z-index: 1000;
+  color: var(--topBarText);
+  background-color: $fallback--fg;
+  background-color: var(--topBar, $fallback--fg);
+  color: $fallback--faint;
+  color: var(--faint, $fallback--faint);
+  box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
+  box-shadow: var(--topBarShadow);
+  box-sizing: border-box;
+  height: var(--navbar-height);
+  position: fixed;
+}
+
+#sidebar {
+  grid-area: sidebar;
+}
+
+.column.-scrollable {
+  top: var(--navbar-height);
+  position: sticky;
+}
+
+#main-scroller {
+  grid-area: content;
+  position: relative;
+}
+
+#notifs-column {
+  grid-area: notifs;
+}
+
+.app-bg-wrapper {
+  position: fixed;
+  height: 100%;
+  top: var(--navbar-height);
+  z-index: -1000;
+  left: 0;
+  right: -20px;
+  background-size: cover;
+  background-repeat: no-repeat;
+  background-color: var(--wallpaper);
+  background-image: var(--body-background-image);
+  background-position: 50%;
+}
+
+.underlay {
+  grid-column-start: 1;
+  grid-column-end: span 3;
+  grid-row-start: 1;
+  grid-row-end: 1;
+  pointer-events: none;
+  background-color: rgba(0, 0, 0, 0.15);
+  background-color: var(--underlay, rgba(0, 0, 0, 0.15));
+  z-index: -1000;
+}
+
+.app-layout {
+  --miniColumn: 25rem;
+  --maxiColumn: minmax(var(--miniColumn), 45rem);
+  --columnGap: 1em;
+  --status-margin: 0.75em;
+
+  position: relative;
+  display: grid;
+  grid-template-columns: var(--miniColumn) var(--maxiColumn);
+  grid-template-areas: "sidebar content";
+  grid-template-rows: 1fr;
+  box-sizing: border-box;
+  margin: 0 auto;
+  align-content: flex-start;
+  flex-wrap: wrap;
+  justify-content: center;
+  min-height: 100vh;
+  overflow-x: clip;
+
+  .column {
+    --___columnMargin: var(--columnGap);
+
+    display: grid;
+    grid-template-columns: 100%;
+    box-sizing: border-box;
+    grid-row-start: 1;
+    grid-row-end: 1;
+    margin: 0 calc(var(--___columnMargin) / 2);
+    padding: calc(var(--___columnMargin)) 0;
+    row-gap: var(--___columnMargin);
+    align-content: start;
+
+    &:not(.-scrollable) {
+      margin-top: var(--navbar-height);
+    }
+
+    &:hover {
+      z-index: 2;
+    }
+
+    &.-full-height {
+      margin-bottom: 0;
+      padding-top: 0;
+      padding-bottom: 0;
+    }
+
+    &.-scrollable {
+      --___paddingIncrease: calc(var(--columnGap) / 2);
+
+      position: sticky;
+      top: var(--navbar-height);
+      max-height: calc(100vh - var(--navbar-height));
+      overflow-y: auto;
+      overflow-x: hidden;
+      margin-left: calc(var(--___paddingIncrease) * -1);
+      padding-left: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2);
+
+      // On browsers that don't support hiding scrollbars we enforce "show scrolbars" mode
+      // might implement old style of hiding scrollbars later if there's demand
+      @supports (scrollbar-width: none) or (-webkit-text-fill-color: initial) {
+        &:not(.-show-scrollbar) {
+          scrollbar-width: none;
+          margin-right: calc(var(--___paddingIncrease) * -1);
+          padding-right: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2);
+
+          &::-webkit-scrollbar {
+            display: block;
+            width: 0;
+          }
+        }
+      }
+
+      .panel-heading.-sticky {
+        top: calc(var(--columnGap) / -1);
+      }
+    }
+  }
+
+  &.-has-new-post-button {
+    .column {
+      padding-bottom: 10rem;
+    }
+  }
+
+  &.-no-sticky-headers {
+    .column {
+      .panel-heading.-sticky {
+        position: relative;
+        top: 0;
+      }
+    }
+  }
+
+  .column-inner {
+    display: grid;
+    grid-template-columns: 100%;
+    box-sizing: border-box;
+    row-gap: 1em;
+    align-content: start;
+  }
+
+  &.-reverse:not(.-wide):not(.-mobile) {
+    grid-template-columns: var(--maxiColumn) var(--miniColumn);
+    grid-template-areas: "content sidebar";
+  }
+
+  &.-wide {
+    grid-template-columns: var(--miniColumn) var(--maxiColumn) var(--miniColumn);
+    grid-template-areas: "sidebar content notifs";
+
+    &.-reverse {
+      grid-template-areas: "notifs content sidebar";
+    }
+  }
+
+  &.-mobile {
+    grid-template-columns: 100vw;
+    grid-template-areas: "content";
+    padding: 0;
+
+    .column {
+      margin-left: 0;
+      margin-right: 0;
+      padding-top: 0;
+      margin-top: var(--navbar-height);
+      margin-bottom: 0;
+    }
+
+    .panel-heading,
+    .panel-heading::after,
+    .panel-heading::before,
+    .panel,
+    .panel::after {
+      border-top-left-radius: 0;
+      border-top-right-radius: 0;
+    }
+
+    .underlay,
+    #sidebar,
+    #notifs-column {
+      display: none;
+    }
+  }
+
+  &.-normal {
+    #notifs-column {
+      display: none;
+    }
+  }
+}
+
+.text-center {
+  text-align: center;
+}
+
 .button-default {
   user-select: none;
   color: $fallback--text;
@@ -84,7 +340,7 @@ a {
   cursor: pointer;
   box-shadow: $fallback--buttonShadow;
   box-shadow: var(--buttonShadow);
-  font-size: 14px;
+  font-size: 1em;
   font-family: sans-serif;
   font-family: var(--interfaceFont, sans-serif);
 
@@ -103,12 +359,12 @@ a {
   }
 
   &:hover {
-    box-shadow: 0px 0px 4px rgba(255, 255, 255, 0.3);
+    box-shadow: 0 0 4px rgba(255, 255, 255, 0.3);
     box-shadow: var(--buttonHoverShadow);
   }
 
   &:active {
-    box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset;
+    box-shadow: 0 0 4px 0 rgba(255, 255, 255, 0.3), 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset;
     box-shadow: var(--buttonPressedShadow);
     color: $fallback--text;
     color: var(--btnPressedText, $fallback--text);
@@ -141,7 +397,7 @@ a {
     color: var(--btnToggledText, $fallback--text);
     background-color: $fallback--fg;
     background-color: var(--btnToggled, $fallback--fg);
-    box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset;
+    box-shadow: 0 0 4px 0 rgba(255, 255, 255, 0.3), 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset;
     box-shadow: var(--buttonPressedShadow);
 
     svg,
@@ -191,8 +447,9 @@ a {
   }
 }
 
-input, textarea, .input {
-
+input,
+textarea,
+.input {
   &.unstyled {
     border-radius: 0;
     background: none;
@@ -200,10 +457,12 @@ input, textarea, .input {
     height: unset;
   }
 
+  --_padding: 0.5em;
+
   border: none;
   border-radius: $fallback--inputRadius;
   border-radius: var(--inputRadius, $fallback--inputRadius);
-  box-shadow: 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px 0px 2px 0px rgba(0, 0, 0, 1) inset;
+  box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset, 0 0 2px 0 rgba(0, 0, 0, 1) inset;
   box-shadow: var(--inputShadow);
   background-color: $fallback--fg;
   background-color: var(--input, $fallback--fg);
@@ -211,17 +470,18 @@ input, textarea, .input {
   color: var(--inputText, $fallback--lightText);
   font-family: sans-serif;
   font-family: var(--inputFont, sans-serif);
-  font-size: 14px;
+  font-size: 1em;
   margin: 0;
   box-sizing: border-box;
   display: inline-block;
   position: relative;
-  height: 28px;
-  line-height: 16px;
+  line-height: 2;
   hyphens: none;
-  padding: 8px .5em;
+  padding: 0 var(--_padding);
 
-  &:disabled, &[disabled=disabled], &.disabled {
+  &:disabled,
+  &[disabled=disabled],
+  &.disabled {
     cursor: not-allowed;
     opacity: 0.5;
   }
@@ -236,18 +496,21 @@ input, textarea, .input {
 
   &[type=radio] {
     display: none;
+
     &:checked + label::before {
-      box-shadow: 0px 0px 2px black inset, 0px 0px 0px 4px $fallback--fg inset;
-      box-shadow: var(--inputShadow), 0px 0px 0px 4px var(--fg, $fallback--fg) inset;
+      box-shadow: 0 0 2px black inset, 0 0 0 4px $fallback--fg inset;
+      box-shadow: var(--inputShadow), 0 0 0 4px var(--fg, $fallback--fg) inset;
       background-color: var(--accent, $fallback--link);
     }
+
     &:disabled {
       &,
       & + label,
       & + label::before {
-        opacity: .5;
+        opacity: 0.5;
       }
     }
+
     + label::before {
       flex-shrink: 0;
       display: inline-block;
@@ -256,35 +519,37 @@ input, textarea, .input {
       width: 1.1em;
       height: 1.1em;
       border-radius: 100%; // Radio buttons should always be circle
-      box-shadow: 0px 0px 2px black inset;
+      box-shadow: 0 0 2px black inset;
       box-shadow: var(--inputShadow);
-      margin-right: .5em;
+      margin-right: 0.5em;
       background-color: $fallback--fg;
       background-color: var(--input, $fallback--fg);
       vertical-align: top;
       text-align: center;
-      line-height: 1.1em;
+      line-height: 1.1;
       font-size: 1.1em;
       box-sizing: border-box;
       color: transparent;
       overflow: hidden;
-      box-sizing: border-box;
     }
   }
 
   &[type=checkbox] {
     display: none;
+
     &:checked + label::before {
       color: $fallback--text;
       color: var(--inputText, $fallback--text);
     }
+
     &:disabled {
       &,
       & + label,
       & + label::before {
-        opacity: .5;
+        opacity: 0.5;
       }
     }
+
     + label::before {
       flex-shrink: 0;
       display: inline-block;
@@ -294,19 +559,18 @@ input, textarea, .input {
       height: 1.1em;
       border-radius: $fallback--checkboxRadius;
       border-radius: var(--checkboxRadius, $fallback--checkboxRadius);
-      box-shadow: 0px 0px 2px black inset;
+      box-shadow: 0 0 2px black inset;
       box-shadow: var(--inputShadow);
-      margin-right: .5em;
+      margin-right: 0.5em;
       background-color: $fallback--fg;
       background-color: var(--input, $fallback--fg);
       vertical-align: top;
       text-align: center;
-      line-height: 1.1em;
+      line-height: 1.1;
       font-size: 1.1em;
       box-sizing: border-box;
       color: transparent;
       overflow: hidden;
-      box-sizing: border-box;
     }
   }
 
@@ -315,6 +579,12 @@ input, textarea, .input {
   }
 }
 
+// Textareas should have stock line-height + vertical padding instead of huge line-height
+textarea {
+  padding: var(--_padding);
+  line-height: var(--post-line-height);
+}
+
 option {
   color: $fallback--text;
   color: var(--text, $fallback--text);
@@ -324,6 +594,7 @@ option {
 
 .hide-number-spinner {
   -moz-appearance: textfield;
+
   &[type=number]::-webkit-inner-spin-button,
   &[type=number]::-webkit-outer-spin-button {
     opacity: 0;
@@ -331,11 +602,6 @@ option {
   }
 }
 
-i[class*=icon-], .svg-inline--fa  {
-  color: $fallback--icon;
-  color: var(--icon, $fallback--icon);
-}
-
 .btn-block {
   display: block;
   width: 100%;
@@ -362,273 +628,16 @@ i[class*=icon-], .svg-inline--fa  {
   }
 }
 
-.container {
-  display: flex;
-  flex-wrap: wrap;
-  margin: 0;
-  padding: 0 10px 0 10px;
-}
-
-.auto-size {
-  flex: 1
-}
-
-main-router {
-  flex: 1;
-}
-
-.status.compact {
-  color: rgba(0, 0, 0, 0.42);
-  font-weight: 300;
-
-  p {
-    margin: 0;
-    font-size: 0.8em
-  }
-}
-
-/* Panel */
-
-.panel {
-  display: flex;
-  position: relative;
-
-  flex-direction: column;
-  margin: 0.5em;
-
-  background-color: $fallback--bg;
-  background-color: var(--bg, $fallback--bg);
-
-  &::after, & {
-    border-radius: $fallback--panelRadius;
-    border-radius: var(--panelRadius, $fallback--panelRadius);
-  }
-
-  &::after {
-    content: '';
-    position: absolute;
-
-    top: 0;
-    bottom: 0;
-    left: 0;
-    right: 0;
-
-    pointer-events: none;
-
-    box-shadow: 1px 1px 4px rgba(0,0,0,.6);
-    box-shadow: var(--panelShadow);
-  }
-}
-
-.panel-body:empty::before {
-  content: "¯\\_(ツ)_/¯"; // Could use words but it'd require translations
-  display: block;
-  margin: 1em;
-  text-align: center;
-}
-
-.panel-heading {
-  display: flex;
-  flex: none;
-  border-radius: $fallback--panelRadius $fallback--panelRadius 0 0;
-  border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0;
-  background-size: cover;
-  padding: .6em .6em;
-  text-align: left;
-  line-height: 28px;
-  color: var(--panelText);
-  background-color: $fallback--fg;
-  background-color: var(--panel, $fallback--fg);
-  align-items: baseline;
-  box-shadow: var(--panelHeaderShadow);
-
-  .title {
-    flex: 1 0 auto;
-    font-size: 1.3em;
-  }
-
-  .faint {
-    background-color: transparent;
-    color: $fallback--faint;
-    color: var(--panelFaint, $fallback--faint);
-  }
-
-  .faint-link {
-    color: $fallback--faint;
-    color: var(--faintLink, $fallback--faint);
-  }
-
-  .alert {
-    white-space: nowrap;
-    text-overflow: ellipsis;
-    overflow-x: hidden;
-  }
-
-  .button-default,
-  .alert {
-    // height: 100%;
-    line-height: 21px;
-    min-height: 0;
-    box-sizing: border-box;
-    margin: 0;
-    margin-left: .5em;
-    min-width: 1px;
-    align-self: stretch;
-  }
-
-  .button-default {
-    flex-shrink: 0;
-
-    &,
-    i[class*=icon-] {
-      color: $fallback--text;
-      color: var(--btnPanelText, $fallback--text);
-    }
-
-    &:active {
-      background-color: $fallback--fg;
-      background-color: var(--btnPressedPanel, $fallback--fg);
-      color: $fallback--text;
-      color: var(--btnPressedPanelText, $fallback--text);
-    }
-
-    &:disabled {
-      color: $fallback--text;
-      color: var(--btnDisabledPanelText, $fallback--text);
-    }
-
-    &.toggled {
-      color: $fallback--text;
-      color: var(--btnToggledPanelText, $fallback--text);
-    }
-  }
-
-  a,
-  .-link {
-    color: $fallback--link;
-    color: var(--panelLink, $fallback--link)
-  }
-}
-
-.panel-heading.stub {
-  border-radius: $fallback--panelRadius;
-  border-radius: var(--panelRadius, $fallback--panelRadius);
-}
-
-/* TODO Should remove timeline-footer from here when we refactor panels into
- * separate component and utilize slots
- */
-.panel-footer, .timeline-footer {
-  display: flex;
-  border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
-  border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
-  flex: none;
-  padding: 0.6em 0.6em;
-  text-align: left;
-  line-height: 28px;
-  align-items: baseline;
-  border-width: 1px 0 0 0;
-  border-style: solid;
-  border-color: var(--border, $fallback--border);
-
-  .faint {
-    color: $fallback--faint;
-    color: var(--panelFaint, $fallback--faint);
-  }
-
-  a,
-  .-link {
-    color: $fallback--link;
-    color: var(--panelLink, $fallback--link);
-  }
-}
-
-.panel-body > p {
-  line-height: 18px;
-  padding: 1em;
-  margin: 0;
-}
-
-.container > * {
-  min-width: 0px;
-}
+@import './panel.scss';
 
 .fa {
   color: grey;
 }
 
-nav {
-  z-index: 1000;
-  color: var(--topBarText);
-  background-color: $fallback--fg;
-  background-color: var(--topBar, $fallback--fg);
-  color: $fallback--faint;
-  color: var(--faint, $fallback--faint);
-  box-shadow: 0px 0px 4px rgba(0,0,0,.6);
-  box-shadow: var(--topBarShadow);
-  box-sizing: border-box;
-}
-
-.fade-enter-active, .fade-leave-active {
-  transition: opacity .2s
-}
-.fade-enter-from, .fade-leave-active {
-  opacity: 0
-}
-
-.main {
-  flex-basis: 50%;
-  flex-grow: 1;
-  flex-shrink: 1;
-}
-
-.sidebar-bounds {
-  flex: 0;
-  flex-basis: 35%;
-}
-
-.sidebar-flexer {
-  flex: 1;
-  flex-basis: 345px;
-  width: 365px;
-}
-
 .mobile-shown {
   display: none;
 }
 
-@media all and (min-width: 800px) {
-  body {
-    overflow-y: scroll;
-  }
-
-  .sidebar-bounds {
-    overflow: hidden;
-    max-height: 100vh;
-    width: 345px;
-    position: fixed;
-    margin-top: -10px;
-
-    .sidebar-scroller {
-      height: 96vh;
-      width: 365px;
-      padding-top: 10px;
-      padding-right: 50px;
-      overflow-x: hidden;
-      overflow-y: scroll;
-    }
-
-    .sidebar {
-      width: 345px;
-    }
-  }
-  .sidebar-flexer {
-    max-height: 96vh;
-    flex-shrink: 0;
-    flex-grow: 0;
-  }
-}
-
 .badge {
   box-sizing: border-box;
   display: inline-block;
@@ -656,12 +665,10 @@ nav {
 }
 
 .alert {
-  margin: 0.35em;
-  padding: 0.25em;
+  margin: 0 0.35em;
+  padding: 0 0.25em;
   border-radius: $fallback--tooltipRadius;
   border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
-  min-height: 28px;
-  line-height: 28px;
 
   &.error {
     background-color: $fallback--alertError;
@@ -712,7 +719,7 @@ nav {
 }
 
 .visibility-notice {
-  padding: .5em;
+  padding: 0.5em;
   border: 1px solid $fallback--faint;
   border: 1px solid var(--faint, $fallback--faint);
   border-radius: $fallback--inputRadius;
@@ -727,7 +734,7 @@ nav {
     position: absolute;
     top: 0;
     right: 0;
-    padding: .5em;
+    padding: 0.5em;
     color: inherit;
   }
 }
@@ -744,72 +751,6 @@ nav {
   }
 }
 
-@keyframes shakeError {
-  0% {
-    transform: translateX(0);
-  }
-  15% {
-    transform: translateX(0.375rem);
-  }
-  30% {
-    transform: translateX(-0.375rem);
-  }
-  45% {
-    transform: translateX(0.375rem);
-  }
-  60% {
-    transform: translateX(-0.375rem);
-  }
-  75% {
-    transform: translateX(0.375rem);
-  }
-  90% {
-    transform: translateX(-0.375rem);
-  }
-  100% {
-    transform: translateX(0);
-  }
-}
-
-@media all and (max-width: 800px) {
-  .mobile-hidden {
-    display: none;
-  }
-
-  .panel-switcher {
-    display: flex;
-  }
-
-  .container {
-    padding: 0;
-  }
-
-  .panel {
-    margin: 0.5em 0 0.5em 0;
-  }
-
-  .menu-button {
-    display: block;
-    margin-right: 0.8em;
-  }
-
-  .main {
-    margin-bottom: 7em;
-  }
-}
-
-.setting-list,
-.option-list{
-  list-style-type: none;
-  padding-left: 2em;
-  li {
-    margin-bottom: 0.5em;
-  }
-  .suboptions {
-    margin-top: 0.3em
-  }
-}
-
 .login-hint {
   text-align: center;
 
@@ -819,18 +760,26 @@ nav {
 
   a {
     display: inline-block;
-    padding: 1em 0px;
+    padding: 1em 0;
     width: 100%;
   }
 }
 
 .btn.button-default {
-  min-height: 28px;
+  min-height: 2em;
 }
 
-.animate-spin {
-  animation: spin 2s infinite linear;
-  display: inline-block;
+.new-status-notification {
+  position: relative;
+  font-size: 1.1em;
+  z-index: 1;
+  flex: 1;
+}
+
+@media all and (max-width: 800px) {
+  .mobile-hidden {
+    display: none;
+  }
 }
 
 @keyframes spin {
@@ -843,49 +792,47 @@ nav {
   }
 }
 
-.new-status-notification {
-  position: relative;
-  font-size: 1.1em;
-  z-index: 1;
-  flex: 1;
-}
+@keyframes shakeError {
+  0% {
+    transform: translateX(0);
+  }
 
-.chat-layout {
-  // Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens).
-  overflow: hidden;
-  height: 100%;
+  15% {
+    transform: translateX(0.375rem);
+  }
 
-  // Get rid of scrollbar on body as scrolling happens on different element
-  body {
-    overflow: hidden;
+  30% {
+    transform: translateX(-0.375rem);
   }
 
-  // Ensures the fixed position of the mobile browser bars on scroll up / down events.
-  // Prevents the mobile browser bars from overlapping or hiding the message posting form.
-  @media all and (max-width: 800px) {
-    body {
-      height: 100%;
-    }
+  45% {
+    transform: translateX(0.375rem);
+  }
 
-    #app {
-      height: 100%;
-      overflow: hidden;
-      min-height: auto;
-    }
+  60% {
+    transform: translateX(-0.375rem);
+  }
 
-    #app_bg_wrapper {
-      overflow: hidden;
-    }
+  75% {
+    transform: translateX(0.375rem);
+  }
 
-    .main {
-      overflow: hidden;
-      height: 100%;
-    }
+  90% {
+    transform: translateX(-0.375rem);
+  }
 
-    #content {
-      padding-top: 0;
-      height: 100%;
-      overflow: visible;
-    }
+  100% {
+    transform: translateX(0);
   }
 }
+
+// Vue transitions
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.2s;
+}
+
+.fade-enter-from,
+.fade-leave-active {
+  opacity: 0;
+}

+ 18 - 24
src/App.vue

@@ -7,33 +7,26 @@
       id="app_bg_wrapper"
       class="app-bg-wrapper"
     />
-    <MobileNav v-if="isMobileLayout" />
+    <MobileNav v-if="layoutType === 'mobile'" />
     <DesktopNav v-else />
-    <div class="app-bg-wrapper app-container-wrapper" />
+    <notifications v-if="currentUser" />
     <div
       id="content"
-      class="container underlay"
+      class="app-layout container"
+      :class="classes"
     >
-      <div
-        class="sidebar-flexer mobile-hidden"
-        :style="sidebarAlign"
-      >
-        <div class="sidebar-bounds">
-          <div class="sidebar-scroller">
-            <div class="sidebar">
-              <user-panel />
-              <div v-if="!isMobileLayout">
-                <nav-panel />
-                <instance-specific-panel v-if="showInstanceSpecificPanel" />
-                <features-panel v-if="!currentUser && showFeaturesPanel" />
-                <who-to-follow-panel v-if="currentUser && suggestionsEnabled" />
-                <notifications v-if="currentUser" />
-              </div>
-            </div>
-          </div>
-        </div>
+      <div class="underlay"/>
+      <div id="sidebar" class="column -scrollable" :class="{ '-show-scrollbar': showScrollbars }">
+        <user-panel />
+        <template v-if="layoutType !== 'mobile'">
+          <nav-panel />
+          <instance-specific-panel v-if="showInstanceSpecificPanel" />
+          <features-panel v-if="!currentUser && showFeaturesPanel" />
+          <who-to-follow-panel v-if="currentUser && suggestionsEnabled" />
+          <div id="notifs-sidebar" />
+        </template>
       </div>
-      <div class="main">
+      <div id="main-scroller" class="column main" :class="{ '-full-height': isChats }">
         <div
           v-if="!currentUser"
           class="login-hint panel panel-default"
@@ -47,13 +40,14 @@
         </div>
         <router-view />
       </div>
-      <media-modal />
+      <div id="notifs-column" class="column -scrollable" :class="{ '-show-scrollbar': showScrollbars }"/>
     </div>
+    <media-modal />
     <shout-panel
       v-if="currentUser && shout && !hideShoutbox"
       :floating="true"
       class="floating-shout mobile-hidden"
-      :class="{ 'left': shoutboxPosition }"
+      :class="{ '-left': shoutboxPosition }"
     />
     <MobilePostStatusButton />
     <UserReportingModal />

+ 3 - 3
src/boot/after_store.js

@@ -8,7 +8,7 @@ import App from '../App.vue'
 import routes from './routes'
 import VBodyScrollLock from 'src/directives/body_scroll_lock'
 
-import { windowWidth } from '../services/window_utils/window_utils'
+import { windowWidth, windowHeight } from '../services/window_utils/window_utils'
 import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js'
 import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
 import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
@@ -332,8 +332,8 @@ const checkOAuthToken = async ({ store }) => {
 }
 
 const afterStoreSetup = async ({ store, i18n }) => {
-  const width = windowWidth()
-  store.dispatch('setMobileLayout', width <= 800)
+  store.dispatch('setLayoutWidth', windowWidth())
+  store.dispatch('setLayoutHeight', windowHeight())
 
   FaviconService.initFaviconService()
 

+ 1 - 1
src/components/about/about.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="sidebar">
+  <div class="column-inner">
     <instance-specific-panel v-if="showInstanceSpecificPanel" />
     <staff-panel />
     <terms-of-service-panel />

+ 0 - 4
src/components/account_actions/account_actions.vue

@@ -74,10 +74,6 @@
 <style lang="scss">
 @import '../../_variables.scss';
 .AccountActions {
-  button.dropdown-item {
-    margin-left: 0;
-  }
-
   .ellipsis-button {
     width: 2.5em;
     margin: -0.5em 0;

+ 1 - 1
src/components/attachment/attachment.scss

@@ -173,7 +173,7 @@
       margin: 8px;
       word-break: break-all;
       h1 {
-        font-size: 14px;
+        font-size: 1rem;
         margin: 0px;
       }
     }

+ 17 - 80
src/components/chat/chat.js

@@ -6,7 +6,7 @@ import PostStatusForm from '../post_status_form/post_status_form.vue'
 import ChatTitle from '../chat_title/chat_title.vue'
 import chatService from '../../services/chat_service/chat_service.js'
 import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
-import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight, isScrollable } from './chat_layout_utils.js'
+import { getScrollPosition, getNewTopPosition, isBottomedOut, isScrollable } from './chat_layout_utils.js'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faChevronDown,
@@ -20,7 +20,7 @@ library.add(
 )
 
 const BOTTOMED_OUT_OFFSET = 10
-const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
+const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10
 const SAFE_RESIZE_TIME_OFFSET = 100
 const MARK_AS_READ_DELAY = 1500
 const MAX_RETRIES = 10
@@ -43,7 +43,7 @@ const Chat = {
   },
   created () {
     this.startFetching()
-    window.addEventListener('resize', this.handleLayoutChange)
+    window.addEventListener('resize', this.handleResize)
   },
   mounted () {
     window.addEventListener('scroll', this.handleScroll)
@@ -52,15 +52,11 @@ const Chat = {
     }
 
     this.$nextTick(() => {
-      this.updateScrollableContainerHeight()
       this.handleResize()
     })
-    this.setChatLayout()
   },
   unmounted () {
     window.removeEventListener('scroll', this.handleScroll)
-    window.removeEventListener('resize', this.handleLayoutChange)
-    this.unsetChatLayout()
     if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
     this.$store.dispatch('clearCurrentChat')
   },
@@ -96,8 +92,7 @@ const Chat = {
     ...mapState({
       backendInteractor: state => state.api.backendInteractor,
       mastoUserSocketStatus: state => state.api.mastoUserSocketStatus,
-      mobileLayout: state => state.interface.mobileLayout,
-      layoutHeight: state => state.interface.layoutHeight,
+      mobileLayout: state => state.interface.layoutType === 'mobile',
       currentUser: state => state.users.currentUser
     })
   },
@@ -115,9 +110,6 @@ const Chat = {
     '$route': function () {
       this.startFetching()
     },
-    layoutHeight () {
-      this.handleResize({ expand: true })
-    },
     mastoUserSocketStatus (newValue) {
       if (newValue === WSConnectionStatus.JOINED) {
         this.fetchChat({ isFirstFetch: true })
@@ -132,7 +124,6 @@ const Chat = {
     onFilesDropped () {
       this.$nextTick(() => {
         this.handleResize()
-        this.updateScrollableContainerHeight()
       })
     },
     handleVisibilityChange () {
@@ -142,43 +133,7 @@ const Chat = {
         }
       })
     },
-    setChatLayout () {
-      //   This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app).
-      //   This layout prevents empty spaces from being visible at the bottom
-      //   of the chat on iOS Safari (`safe-area-inset`) when
-      //   - the on-screen keyboard appears and the user starts typing
-      //   - the user selects the text inside the input area
-      //   - the user selects and deletes the text that is multiple lines long
-      //   TODO: unify the chat layout with the global layout.
-      let html = document.querySelector('html')
-      if (html) {
-        html.classList.add('chat-layout')
-      }
-
-      this.$nextTick(() => {
-        this.updateScrollableContainerHeight()
-      })
-    },
-    unsetChatLayout () {
-      let html = document.querySelector('html')
-      if (html) {
-        html.classList.remove('chat-layout')
-      }
-    },
-    handleLayoutChange () {
-      this.$nextTick(() => {
-        this.updateScrollableContainerHeight()
-        this.scrollDown()
-      })
-    },
-    // Ensures the proper position of the posting form in the mobile layout (the mobile browser panel does not overlap or hide it)
-    updateScrollableContainerHeight () {
-      const header = this.$refs.header
-      const footer = this.$refs.footer
-      const inner = this.mobileLayout ? window.document.body : this.$refs.inner
-      this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px'
-    },
-    // Preserves the scroll position when OSK appears or the posting form changes its height.
+    // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
     handleResize (opts = {}) {
       const { expand = false, delayed = false } = opts
 
@@ -190,29 +145,20 @@ const Chat = {
       }
 
       this.$nextTick(() => {
-        this.updateScrollableContainerHeight()
-
-        const { offsetHeight = undefined } = this.lastScrollPosition
-        this.lastScrollPosition = getScrollPosition(this.$refs.scrollable)
-
+        const { offsetHeight = undefined } = getScrollPosition()
         const diff = this.lastScrollPosition.offsetHeight - offsetHeight
-        if (diff < 0 || (!this.bottomedOut() && expand)) {
+        if (diff !== 0 || (!this.bottomedOut() && expand)) {
           this.$nextTick(() => {
-            this.updateScrollableContainerHeight()
-            this.$refs.scrollable.scrollTo({
-              top: this.$refs.scrollable.scrollTop - diff,
-              left: 0
-            })
+            window.scrollTo({ top: window.scrollY + diff })
           })
         }
+        this.lastScrollPosition = getScrollPosition()
       })
     },
     scrollDown (options = {}) {
       const { behavior = 'auto', forceRead = false } = options
-      const scrollable = this.$refs.scrollable
-      if (!scrollable) { return }
       this.$nextTick(() => {
-        scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior })
+        window.scrollTo({ top: document.documentElement.scrollHeight, behavior })
       })
       if (forceRead) {
         this.readChat()
@@ -228,11 +174,10 @@ const Chat = {
       })
     },
     bottomedOut (offset) {
-      return isBottomedOut(this.$refs.scrollable, offset)
+      return isBottomedOut(offset)
     },
     reachedTop () {
-      const scrollable = this.$refs.scrollable
-      return scrollable && scrollable.scrollTop <= 0
+      return window.scrollY <= 0
     },
     cullOlderCheck () {
       window.setTimeout(() => {
@@ -263,10 +208,9 @@ const Chat = {
       }
     }, 200),
     handleScrollUp (positionBeforeLoading) {
-      const positionAfterLoading = getScrollPosition(this.$refs.scrollable)
-      this.$refs.scrollable.scrollTo({
-        top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
-        left: 0
+      const positionAfterLoading = getScrollPosition()
+      window.scrollTo({
+        top: getNewTopPosition(positionBeforeLoading, positionAfterLoading)
       })
     },
     fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) {
@@ -285,22 +229,18 @@ const Chat = {
             chatService.clear(chatMessageService)
           }
 
-          const positionBeforeUpdate = getScrollPosition(this.$refs.scrollable)
+          const positionBeforeUpdate = getScrollPosition()
           this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
             this.$nextTick(() => {
               if (fetchOlderMessages) {
                 this.handleScrollUp(positionBeforeUpdate)
               }
 
-              if (isFirstFetch) {
-                this.updateScrollableContainerHeight()
-              }
-
               // In vertical screens, the first batch of fetched messages may not always take the
               // full height of the scrollable container.
               // If this is the case, we want to fetch the messages until the scrollable container
               // is fully populated so that the user has the ability to scroll up and load the history.
-              if (!isScrollable(this.$refs.scrollable) && messages.length > 0) {
+              if (!isScrollable() && messages.length > 0) {
                 this.fetchChat({ maxId: this.currentChatMessageService.minId })
               }
             })
@@ -336,9 +276,6 @@ const Chat = {
         this.handleResize()
         // When the posting form size changes because of a media attachment, we need an extra resize
         // to account for the potential delay in the DOM update.
-        setTimeout(() => {
-          this.updateScrollableContainerHeight()
-        }, SAFE_RESIZE_TIME_OFFSET)
         this.scrollDown({ forceRead: true })
       })
     },

+ 13 - 75
src/components/chat/chat.scss

@@ -1,28 +1,22 @@
 .chat-view {
   display: flex;
-  height: calc(100vh - 60px);
-  width: 100%;
-
-  .chat-title {
-    // prevents chat header jumping on when the user avatar loads
-    height: 28px;
-  }
+  height: 100%;
 
   .chat-view-inner {
     height: auto;
     width: 100%;
     overflow: visible;
     display: flex;
-    margin: 0.5em 0.5em 0 0.5em;
   }
 
   .chat-view-body {
+    box-sizing: border-box;
     background-color: var(--chatBg, $fallback--bg);
     display: flex;
     flex-direction: column;
     width: 100%;
     overflow: visible;
-    min-height: 100%;
+    min-height: calc(100vh - var(--navbar-height));
     margin: 0 0 0 0;
     border-radius: 10px 10px 0 0;
     border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0;
@@ -32,36 +26,32 @@
     }
   }
 
-  .scrollable-message-list {
+  .message-list {
     padding: 0 0.8em;
     height: 100%;
-    overflow-y: scroll;
-    overflow-x: hidden;
     display: flex;
     flex-direction: column;
+    justify-content: end;
   }
 
   .footer {
     position: sticky;
     bottom: 0;
+    background-color: $fallback--bg;
+    background-color: var(--bg, $fallback--bg);
+    z-index: 1;
   }
 
   .chat-view-heading {
-    align-items: center;
-    justify-content: space-between;
-    top: 50px;
-    display: flex;
-    z-index: 2;
-    position: sticky;
-    overflow: hidden;
+    grid-template-columns: auto minmax(50%, 1fr);
   }
 
   .go-back-button {
-    cursor: pointer;
-    width: 28px;
     text-align: center;
-    padding: 0.6em;
-    margin: -0.6em 0.6em -0.6em -0.6em;
+    line-height: 1;
+    height: 100%;
+    align-self: start;
+    width: var(--__panel-heading-height-inner);
   }
 
   .jump-to-bottom-button {
@@ -115,56 +105,4 @@
       }
     }
   }
-
-  @media all and (max-width: 800px) {
-    height: 100%;
-    overflow: hidden;
-
-    .chat-view-inner {
-      overflow: hidden;
-      height: 100%;
-      margin-top: 0;
-      margin-left: 0;
-      margin-right: 0;
-    }
-
-    .chat-view-body {
-      display: flex;
-      min-height: auto;
-      overflow: hidden;
-      height: 100%;
-      margin: 0;
-      border-radius: 0;
-    }
-
-    .chat-view-heading {
-      box-sizing: border-box;
-      position: static;
-      z-index: 9999;
-      top: 0;
-      margin-top: 0;
-      border-radius: 0;
-
-      /* This practically overlays the panel heading color over panel background
-       * color. This is needed because we allow transparent panel background and
-       * it doesn't work well in this "disjointed panel header" case
-       */
-      background:
-        linear-gradient(to top, var(--panel), var(--panel)),
-        linear-gradient(to top, var(--bg), var(--bg));
-      height: 50px;
-    }
-
-    .scrollable-message-list {
-      display: unset;
-      overflow-y: scroll;
-      overflow-x: hidden;
-      -webkit-overflow-scrolling: touch;
-    }
-
-    .footer {
-      position: sticky;
-      bottom: auto;
-    }
-  }
 }

+ 5 - 8
src/components/chat/chat.vue

@@ -2,23 +2,22 @@
   <div class="chat-view">
     <div class="chat-view-inner">
       <div
-        id="nav"
         ref="inner"
         class="panel-default panel chat-view-body"
       >
         <div
           ref="header"
-          class="panel-heading chat-view-heading mobile-hidden"
+          class="panel-heading -sticky chat-view-heading"
         >
-          <a
-            class="go-back-button"
+          <button
+            class="button-unstyled go-back-button"
             @click="goBack"
           >
             <FAIcon
               size="lg"
               icon="chevron-left"
             />
-          </a>
+          </button>
           <div class="title text-center">
             <ChatTitle
               :user="recipient"
@@ -27,10 +26,8 @@
           </div>
         </div>
         <div
-          ref="scrollable"
-          class="scrollable-message-list"
+          class="message-list"
           :style="{ height: scrollableContainerHeight }"
-          @scroll="handleScroll"
         >
           <template v-if="!errorLoadingChat">
             <ChatMessage

+ 9 - 18
src/components/chat/chat_layout_utils.js

@@ -1,9 +1,9 @@
 // Captures a scroll position
-export const getScrollPosition = (el) => {
+export const getScrollPosition = () => {
   return {
-    scrollTop: el.scrollTop,
-    scrollHeight: el.scrollHeight,
-    offsetHeight: el.offsetHeight
+    scrollTop: window.scrollY,
+    scrollHeight: document.documentElement.scrollHeight,
+    offsetHeight: window.innerHeight
   }
 }
 
@@ -13,21 +13,12 @@ export const getNewTopPosition = (previousPosition, newPosition) => {
   return previousPosition.scrollTop + (newPosition.scrollHeight - previousPosition.scrollHeight)
 }
 
-export const isBottomedOut = (el, offset = 0) => {
-  if (!el) { return }
-  const scrollHeight = el.scrollTop + offset
-  const totalHeight = el.scrollHeight - el.offsetHeight
+export const isBottomedOut = (offset = 0) => {
+  const scrollHeight = window.scrollY + offset
+  const totalHeight = document.documentElement.scrollHeight - window.innerHeight
   return totalHeight <= scrollHeight
 }
-
-// Height of the scrollable container. The dynamic height is needed to ensure the mobile browser panel doesn't overlap or hide the posting form.
-export const scrollableContainerHeight = (inner, header, footer) => {
-  return inner.offsetHeight - header.clientHeight - footer.clientHeight
-}
-
 // Returns whether or not the scrollbar is visible.
-export const isScrollable = (el) => {
-  if (!el) return
-
-  return el.scrollHeight > el.clientHeight
+export const isScrollable = () => {
+  return document.documentElement.scrollHeight > window.innerHeight
 }

+ 1 - 1
src/components/chat_list/chat_list.vue

@@ -6,7 +6,7 @@
     v-else
     class="chat-list panel panel-default"
   >
-    <div class="panel-heading">
+    <div class="panel-heading -sticky">
       <span class="title">
         {{ $t("chats.chats") }}
       </span>

+ 2 - 2
src/components/chat_list_item/chat_list_item.scss

@@ -43,7 +43,7 @@
     white-space: nowrap;
     overflow: hidden;
     flex-shrink: 1;
-    line-height: 1.4em;
+    line-height: var(--post-line-height);
   }
 
   .chat-preview {
@@ -82,7 +82,7 @@
   }
 
   .time-wrapper {
-    line-height: 1.4em;
+    line-height: var(--post-line-height);
   }
 
   .chat-preview-body {

+ 4 - 4
src/components/chat_new/chat_new.scss

@@ -22,10 +22,10 @@
   }
 
   .go-back-button {
-    cursor: pointer;
-    width: 28px;
     text-align: center;
-    padding: 0.6em;
-    margin: -0.6em 0.6em -0.6em -0.6em;
+    line-height: 1;
+    height: 100%;
+    align-self: start;
+    width: var(--__panel-heading-height-inner);
   }
 }

+ 3 - 4
src/components/chat_new/chat_new.vue

@@ -1,21 +1,20 @@
 <template>
   <div
-    id="nav"
     class="panel-default panel chat-new"
   >
     <div
       ref="header"
       class="panel-heading"
     >
-      <a
-        class="go-back-button"
+      <button
+        class="button-unstyled go-back-button"
         @click="goBack"
       >
         <FAIcon
           size="lg"
           icon="chevron-left"
         />
-      </a>
+      </button>
     </div>
     <div class="input-wrap">
       <div class="input-search">

+ 11 - 8
src/components/chat_title/chat_title.vue

@@ -4,19 +4,19 @@
     :title="title"
   >
     <router-link
+      class="avatar-container"
       v-if="withAvatar && user"
       :to="getUserProfileLink(user)"
     >
       <UserAvatar
+        class="titlebar-avatar"
         :user="user"
-        width="23px"
-        height="23px"
       />
     </router-link>
     <RichContent
       v-if="user"
       class="username"
-      :title="'@'+user.screen_name_ui"
+      :title="'@'+(user && user.screen_name_ui)"
       :html="htmlTitle"
       :emoji="user.emoji || []"
     />
@@ -33,7 +33,6 @@
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
-  align-items: center;
 
   --emoji-size: 14px;
 
@@ -46,11 +45,15 @@
     overflow: hidden;
   }
 
-  .Avatar {
-    width: 23px;
-    height: 23px;
-    margin-right: 0.5em;
+  .avatar-container {
+    align-self: center;
+    line-height: 1;
+  }
 
+  .titlebar-avatar {
+    margin-right: 0.5em;
+    height: 1.5em;
+    width: 1.5em;
     border-radius: $fallback--avatarAltRadius;
     border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
 

+ 9 - 1
src/components/conversation/conversation.vue

@@ -7,7 +7,7 @@
   >
     <div
       v-if="isExpanded"
-      class="panel-heading conversation-heading"
+      class="panel-heading conversation-heading -sticky"
     >
       <span class="title"> {{ $t('timeline.conversation') }} </span>
       <button
@@ -201,6 +201,8 @@
 @import '../../_variables.scss';
 
 .Conversation {
+  z-index: 1;
+
   .conversation-dive-to-top-level-box {
     padding: var(--status-margin, $status-margin);
     border-bottom-width: 1px;
@@ -223,6 +225,7 @@
     --text: var(--faint);
     color: var(--text);
   }
+
   .thread-ancestor-dive-box {
     padding-left: var(--status-margin, $status-margin);
     border-bottom-width: 1px;
@@ -250,6 +253,7 @@
   .thread-ancestor-has-other-replies .conversation-status,
   .thread-ancestor:last-child .conversation-status,
   .thread-ancestor:last-child .thread-ancestor-dive-box,
+  &:last-child .conversation-status,
   &.-expanded .thread-tree .conversation-status {
     border-bottom: none;
   }
@@ -270,5 +274,9 @@
     border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
     border-bottom: 1px solid var(--border, $fallback--border);
   }
+
+  &.-expanded.status-fadein {
+    margin: calc(var(--status-margin, $status-margin) / 2);
+  }
 }
 </style>

+ 5 - 7
src/components/desktop_nav/desktop_nav.scss

@@ -1,9 +1,7 @@
 @import '../../_variables.scss';
 
 .DesktopNav {
-  height: 50px;
   width: 100%;
-  position: fixed;
 
   a {
     color: var(--topBarLink, $fallback--link);
@@ -11,7 +9,7 @@
 
   .inner-nav {
     display: grid;
-    grid-template-rows: 50px;
+    grid-template-rows: var(--navbar-height);
     grid-template-columns: 2fr auto 2fr;
     grid-template-areas: "sitename logo actions";
     box-sizing: border-box;
@@ -20,7 +18,7 @@
     max-width: 980px;
   }
 
-  &.-logoLeft {
+  &.-logoLeft .inner-nav {
     grid-template-columns: auto 2fr 2fr;
     grid-template-areas: "logo sitename actions";
   }
@@ -77,7 +75,7 @@
 
     img {
       display: inline-block;
-      height: 50px;
+      height: var(--navbar-height);
     }
   }
 
@@ -103,8 +101,8 @@
 
   .item {
     flex: 1;
-    line-height: 50px;
-    height: 50px;
+    line-height: var(--navbar-height);
+    height: var(--navbar-height);
     overflow: hidden;
     display: flex;
     flex-wrap: wrap;

+ 0 - 9
src/components/dialog_modal/dialog_modal.vue

@@ -58,16 +58,7 @@
   background-color: var(--bg, $fallback--bg);
 
   .dialog-modal-heading {
-    padding: .5em .5em;
-    margin-right: auto;
-    margin-bottom: 0;
-    white-space: nowrap;
-    color: var(--panelText);
-    background-color: $fallback--fg;
-    background-color: var(--panel, $fallback--fg);
-
     .title {
-      margin-bottom: 0;
       text-align: center;
     }
   }

+ 1 - 1
src/components/emoji_input/emoji_input.vue

@@ -78,7 +78,7 @@
     top: 0;
     right: 0;
     margin: .2em .25em;
-    font-size: 16px;
+    font-size: 1.3em;
     cursor: pointer;
     line-height: 24px;
 

+ 5 - 3
src/components/emoji_picker/emoji_picker.scss

@@ -7,7 +7,7 @@
   right: 0;
   left: 0;
   margin: 0 !important;
-  z-index: 1;
+  z-index: 100;
   background-color: $fallback--bg;
   background-color: var(--popover, $fallback--bg);
   color: $fallback--link;
@@ -73,12 +73,13 @@
     &-item {
       padding: 0 7px;
       cursor: pointer;
-      font-size: 24px;
+      font-size: 1.85em;
 
       &.disabled {
         opacity: 0.5;
         pointer-events: none;
       }
+
       &.active {
         border-bottom: 4px solid;
 
@@ -151,9 +152,10 @@
       justify-content: left;
 
       &-title {
-        font-size: 12px;
+        font-size: 0.85em;
         width: 100%;
         margin: 0;
+
         &.disabled {
           display: none;
         }

+ 5 - 6
src/components/global_notice_list/global_notice_list.vue

@@ -44,20 +44,18 @@
     max-width: calc(100% - 3em);
     display: flex;
     padding-left: 1.5em;
-    line-height: 2em;
+    line-height: 2;
+    margin-bottom: 0.5em;
+
     .notice-message {
       flex: 1 1 100%;
     }
-    i {
-      flex: 0 0;
-      width: 1.5em;
-      cursor: pointer;
-    }
   }
 
   .global-error {
     background-color: var(--alertPopupError, $fallback--cRed);
     color: var(--alertPopupErrorText, $fallback--text);
+
     .svg-inline--fa {
       color: var(--alertPopupErrorText, $fallback--text);
     }
@@ -66,6 +64,7 @@
   .global-warning {
     background-color: var(--alertPopupWarning, $fallback--cOrange);
     color: var(--alertPopupWarningText, $fallback--text);
+
     .svg-inline--fa {
       color: var(--alertPopupWarningText, $fallback--text);
     }

+ 1 - 1
src/components/link-preview/link-preview.vue

@@ -63,7 +63,7 @@
   }
 
   .card-host {
-    font-size: 12px;
+    font-size: 0.85em;
   }
 
   .card-description {

+ 1 - 1
src/components/login_form/login_form.vue

@@ -101,7 +101,7 @@
   padding: 0.6em;
 
   .btn {
-    min-height: 28px;
+    min-height: 2em;
     width: 10em;
   }
 

+ 2 - 2
src/components/media_modal/media_modal.vue

@@ -121,7 +121,7 @@ $modal-view-button-icon-width: 3em;
 $modal-view-button-icon-margin: 0.5em;
 
 .modal-view.media-modal-view {
-  z-index: 1001;
+  z-index: 9000;
   flex-direction: column;
 
   .modal-view-button-arrow,
@@ -234,7 +234,7 @@ $modal-view-button-icon-margin: 0.5em;
       position: absolute;
       height: $modal-view-button-icon-height;
       width: $modal-view-button-icon-width;
-      font-size: 14px;
+      font-size: 1rem;
       line-height: $modal-view-button-icon-height;
       color: #FFF;
       text-align: center;

+ 6 - 2
src/components/media_upload/media_upload.vue

@@ -17,9 +17,9 @@
     />
     <input
       v-if="uploadReady"
+      class="hidden-input-file"
       :disabled="disabled"
       type="file"
-      style="position: fixed; top: -100em"
       multiple="true"
       @change="change"
     >
@@ -32,6 +32,10 @@
 @import '../../_variables.scss';
 
 .media-upload {
-  cursor: pointer;
+  cursor: pointer; // We use <label> for interactivity... i wonder if it's fine
+
+  .hidden-input-file {
+    display: none;
+  }
 }
  </style>

+ 2 - 1
src/components/mobile_nav/mobile_nav.js

@@ -78,7 +78,8 @@ const MobileNav = {
       this.$store.dispatch('logout')
     },
     markNotificationsAsSeen () {
-      this.$refs.notifications.markAsSeen()
+      // this.$refs.notifications.markAsSeen()
+      this.$store.dispatch('markNotificationsAsSeen')
     },
     onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) {
       if (scrollTop + clientHeight >= scrollHeight) {

+ 6 - 11
src/components/mobile_nav/mobile_nav.vue

@@ -5,7 +5,6 @@
     <nav
       id="nav"
       class="mobile-nav"
-      :class="{ 'mobile-hidden': isChat }"
       @click="scrollToTop()"
     >
       <div class="item">
@@ -51,7 +50,7 @@
     <div
       v-if="currentUser"
       class="mobile-notifications-drawer"
-      :class="{ 'closed': !notificationsOpen }"
+      :class="{ '-closed': !notificationsOpen }"
       @touchstart.stop="notificationsTouchStart"
       @touchmove.stop="notificationsTouchMove"
     >
@@ -69,12 +68,9 @@
       </div>
       <div
         class="mobile-notifications"
+        id="mobile-notifications"
         @scroll="onScroll"
       >
-        <Notifications
-          ref="notifications"
-          :no-heading="true"
-        />
       </div>
     </div>
     <SideDrawer
@@ -92,12 +88,10 @@
 .MobileNav {
   .mobile-nav {
     display: grid;
-    line-height: 50px;
-    height: 50px;
+    line-height: var(--navbar-height);
     grid-template-rows: 50px;
     grid-template-columns: 2fr auto;
     width: 100%;
-    position: fixed;
     box-sizing: border-box;
     a {
       color: var(--topBarLink, $fallback--link);
@@ -156,8 +150,9 @@
     z-index: 1001;
     -webkit-overflow-scrolling: touch;
 
-    &.closed {
+    &.-closed {
       transform: translateX(100%);
+      box-shadow: none;
     }
   }
 
@@ -185,7 +180,7 @@
   .mobile-notifications {
     margin-top: 50px;
     width: 100vw;
-    height: calc(100vh - 50px);
+    height: calc(100vh - var(--navbar-height));
     overflow-x: hidden;
     overflow-y: scroll;
 

+ 1 - 1
src/components/mobile_post_status_button/mobile_post_status_button.js

@@ -45,7 +45,7 @@ const MobilePostStatusButton = {
       return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
     },
     isPersistent () {
-      return !!this.$store.getters.mergedConfig.showNewPostButton
+      return !!this.$store.getters.mergedConfig.alwaysShowNewPostButton
     },
     autohideFloatingPostButton () {
       return !!this.$store.getters.mergedConfig.autohideFloatingPostButton

+ 1 - 1
src/components/modal/modal.vue

@@ -35,7 +35,7 @@ export default {
 
 <style lang="scss">
 .modal-view {
-  z-index: 1000;
+  z-index: 2000;
   position: fixed;
   top: 0;
   left: 0;

+ 6 - 7
src/components/notifications/notification_filters.vue

@@ -73,7 +73,7 @@
       </div>
     </template>
     <template v-slot:trigger>
-      <button class="button-unstyled">
+      <button class="filter-trigger-button button-unstyled">
         <FAIcon icon="filter" />
       </button>
     </template>
@@ -116,15 +116,14 @@ export default {
   align-self: stretch;
 
   > button {
-    font-size: 1.2em;
-    padding-left: 0.7em;
-    padding-right: 0.2em;
     line-height: 100%;
     height: 100%;
-  }
+    width: var(--__panel-heading-height-inner);
+    text-align: center;
 
-  .dropdown-item {
-    margin: 0;
+    svg {
+      font-size: 1.2em;
+    }
   }
 }
 

+ 12 - 2
src/components/notifications/notifications.js

@@ -23,8 +23,6 @@ const Notifications = {
     NotificationFilters
   },
   props: {
-    // Disables display of panel header
-    noHeading: Boolean,
     // Disables panel styles, unread mark, potentially other notification-related actions
     // meant for "Interactions" timeline
     minimalMode: Boolean,
@@ -65,6 +63,18 @@ const Notifications = {
     loading () {
       return this.$store.state.statuses.notifications.loading
     },
+    noHeading () {
+      const { layoutType } = this.$store.state.interface
+      return this.minimalMode || layoutType === 'mobile'
+    },
+    teleportTarget () {
+      const { layoutType } = this.$store.state.interface
+      const map = {
+        wide: '#notifs-column',
+        mobile: '#mobile-notifications'
+      }
+      return map[layoutType] || '#notifs-sidebar'
+    },
     notificationsToDisplay () {
       return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
     },

+ 10 - 9
src/components/notifications/notifications.scss

@@ -11,10 +11,6 @@
     color: var(--text, $fallback--text);
   }
 
-  .notifications-footer {
-    border: none;
-  }
-
   .notification {
     position: relative;
 
@@ -47,6 +43,10 @@
     }
   }
 
+  &:last-child .Notification {
+    border-bottom: none;
+  }
+
   .non-mention {
     display: flex;
     flex: 1;
@@ -113,13 +113,13 @@
   }
 
   .emoji-reaction-emoji {
-    font-size: 16px;
+    font-size: 1.3em;
   }
 
   .notification-details {
-    min-width: 0px;
+    min-width: 0;
     word-wrap: break-word;
-    line-height:18px;
+    line-height: var(--post-line-height);
     position: relative;
     overflow: hidden;
     width: 100%;
@@ -142,7 +142,7 @@
     }
 
     .timeago {
-      margin-right: .2em;
+      margin-right: 0.2em;
     }
 
     .status-content {
@@ -155,7 +155,8 @@
       margin: 0 0 0.3em;
       padding: 0;
       font-size: 1em;
-      line-height:20px;
+      line-height: 1.5;
+
       small {
         font-weight: lighter;
       }

+ 59 - 57
src/components/notifications/notifications.vue

@@ -1,69 +1,71 @@
 <template>
-  <div
-    :class="{ minimal: minimalMode }"
-    class="Notifications"
-  >
-    <div :class="mainClass">
-      <div
-        v-if="!noHeading"
-        class="panel-heading"
-      >
-        <div class="title">
-          {{ $t('notifications.notifications') }}
-          <span
-            v-if="unseenCount"
-            class="badge badge-notification unseen-count"
-          >{{ unseenCount }}</span>
-        </div>
-        <button
-          v-if="unseenCount"
-          class="button-default read-button"
-          @click.prevent="markAsSeen"
-        >
-          {{ $t('notifications.read') }}
-        </button>
-        <NotificationFilters />
-      </div>
-      <div class="panel-body">
+  <teleport :disabled="minimalMode" :to="teleportTarget">
+    <div
+      :class="{ minimal: minimalMode }"
+      class="Notifications"
+    >
+      <div :class="mainClass">
         <div
-          v-for="notification in notificationsToDisplay"
-          :key="notification.id"
-          class="notification"
-          :class="{&quot;unseen&quot;: !minimalMode && !notification.seen}"
+          v-if="!noHeading"
+          class="notifications-heading panel-heading -sticky"
         >
-          <div class="notification-overlay" />
-          <notification :notification="notification" />
+          <div class="title">
+            {{ $t('notifications.notifications') }}
+            <span
+              v-if="unseenCount"
+              class="badge badge-notification unseen-count"
+            >{{ unseenCount }}</span>
+          </div>
+          <button
+            v-if="unseenCount"
+            class="button-default read-button"
+            @click.prevent="markAsSeen"
+          >
+            {{ $t('notifications.read') }}
+          </button>
+          <NotificationFilters />
         </div>
-      </div>
-      <div class="panel-footer notifications-footer">
-        <div
-          v-if="bottomedOut"
-          class="new-status-notification text-center faint"
-        >
-          {{ $t('notifications.no_more_notifications') }}
+        <div class="panel-body">
+          <div
+            v-for="notification in notificationsToDisplay"
+            :key="notification.id"
+            class="notification"
+            :class="{unseen: !minimalMode && !notification.seen}"
+          >
+            <div class="notification-overlay" />
+            <notification :notification="notification" />
+          </div>
         </div>
-        <button
-          v-else-if="!loading"
-          class="button-unstyled -link -fullwidth"
-          @click.prevent="fetchOlderNotifications()"
-        >
-          <div class="new-status-notification text-center">
-            {{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older') }}
+        <div class="panel-footer">
+          <div
+            v-if="bottomedOut"
+            class="new-status-notification text-center faint"
+          >
+            {{ $t('notifications.no_more_notifications') }}
+          </div>
+          <button
+            v-else-if="!loading"
+            class="button-unstyled -link -fullwidth"
+            @click.prevent="fetchOlderNotifications()"
+          >
+            <div class="new-status-notification text-center">
+              {{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older') }}
+            </div>
+          </button>
+          <div
+            v-else
+            class="new-status-notification text-center"
+          >
+            <FAIcon
+              icon="circle-notch"
+              spin
+              size="lg"
+            />
           </div>
-        </button>
-        <div
-          v-else
-          class="new-status-notification text-center"
-        >
-          <FAIcon
-            icon="circle-notch"
-            spin
-            size="lg"
-          />
         </div>
       </div>
     </div>
-  </div>
+  </teleport>
 </template>
 
 <script src="./notifications.js"></script>

+ 7 - 3
src/components/password_reset/password_reset.vue

@@ -91,14 +91,18 @@
     flex-direction: column;
     margin-top: 0.6em;
     max-width: 18rem;
+
+    > * {
+      min-width: 0;
+    }
   }
 
   .form-group {
     display: flex;
     flex-direction: column;
     margin-bottom: 1em;
-    padding: 0.3em 0.0em 0.3em;
-    line-height: 24px;
+    padding: 0.3em 0;
+    line-height: 1.85em;
   }
 
   .error {
@@ -110,7 +114,7 @@
 
   .alert {
     padding: 0.5em;
-    margin: 0.3em 0.0em 1em;
+    margin: 0.3em 0 1em;
   }
 
   .password-reset-required {

+ 29 - 13
src/components/popover/popover.vue

@@ -5,7 +5,7 @@
   >
     <button
       ref="trigger"
-      class="button-unstyled -fullwidth popover-trigger-button"
+      class="button-unstyled popover-trigger-button"
       type="button"
       @click="onClick"
     >
@@ -37,7 +37,7 @@
 }
 
 .popover {
-  z-index: 8;
+  z-index: 500;
   position: absolute;
   min-width: 0;
 }
@@ -45,8 +45,19 @@
 .popover-default {
   transition: opacity 0.3s;
 
-  box-shadow: 1px 1px 4px rgba(0,0,0,.6);
-  box-shadow: var(--panelShadow);
+  &:after {
+    content: '';
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    z-index: 3;
+    box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
+    box-shadow: var(--panelShadow);
+    pointer-events: none;
+  }
+
   border-radius: $fallback--btnRadius;
   border-radius: var(--btnRadius, $fallback--btnRadius);
 
@@ -65,11 +76,11 @@
 .dropdown-menu {
   display: block;
   padding: .5rem 0;
-  font-size: 1rem;
+  font-size: 1em;
   text-align: left;
   list-style: none;
   max-width: 100vw;
-  z-index: 10;
+  z-index: 200;
   white-space: nowrap;
 
   .dropdown-divider {
@@ -82,9 +93,9 @@
 
   .dropdown-item {
     line-height: 21px;
-    overflow: auto;
+    overflow: hidden;
     display: block;
-    padding: .5em 0.75em;
+    padding: 0.5em 0.75em;
     clear: both;
     font-weight: 400;
     text-align: inherit;
@@ -110,14 +121,15 @@
     &:active, &:hover {
       background-color: $fallback--lightBg;
       background-color: var(--selectedMenuPopover, $fallback--lightBg);
-      color: $fallback--link;
-      color: var(--selectedMenuPopoverText, $fallback--link);
+      box-shadow: none;
+      --btnText: var(--selectedMenuPopoverText, $fallback--link);
       --faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
       --faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
       --lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
       --icon: var(--selectedMenuPopoverIcon, $fallback--icon);
       svg {
         color: var(--selectedMenuPopoverIcon, $fallback--icon);
+        --icon: var(--selectedMenuPopoverIcon, $fallback--icon);
       }
     }
 
@@ -142,9 +154,13 @@
         content: '✓';
       }
 
-      &.menu-checkbox-radio::after {
-        font-size: 2em;
-        content: '•';
+      &.-radio {
+        border-radius: 9999px;
+
+        &.menu-checkbox-checked::after {
+          font-size: 2em;
+          content: '•';
+        }
       }
     }
 

+ 1 - 1
src/components/post_status_form/post_status_form.js

@@ -486,7 +486,7 @@ const PostStatusForm = {
       const bottomBottomPaddingStr = window.getComputedStyle(bottomRef)['padding-bottom']
       const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
 
-      const scrollerRef = this.$el.closest('.sidebar-scroller') ||
+      const scrollerRef = this.$el.closest('.column.-scrollable') ||
             this.$el.closest('.post-form-modal-view') ||
             window
 

+ 26 - 43
src/components/post_status_form/post_status_form.vue

@@ -8,15 +8,6 @@
       @submit.prevent
       @dragover.prevent="fileDrag"
     >
-      <div
-        v-show="showDropIcon !== 'hide'"
-        :style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
-        class="drop-indicator"
-        @dragleave="fileDragStop"
-        @drop.stop="fileDrop"
-      >
-        <FAIcon :icon="uploadFileLimitReached ? 'ban' : 'upload'" />
-      </div>
       <div class="form-group">
         <i18n-t
           v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
@@ -277,6 +268,15 @@
           {{ $t('post_status.post') }}
         </button>
       </div>
+      <div
+        v-show="showDropIcon !== 'hide'"
+        :style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
+        class="drop-indicator"
+        @dragleave="fileDragStop"
+        @drop.stop="fileDrop"
+      >
+        <FAIcon :icon="uploadFileLimitReached ? 'ban' : 'upload'" />
+      </div>
       <div
         v-if="error"
         class="alert error"
@@ -336,7 +336,7 @@
     display: flex;
     justify-content: space-between;
     padding: 0.5em;
-    height: 32px;
+    height: 2.5em;
 
     button {
       width: 10em;
@@ -394,7 +394,6 @@
     border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
     padding: 0.5em;
     margin: 0;
-    line-height: 1.4em;
   }
 
   .text-format {
@@ -408,13 +407,16 @@
     display: flex;
     justify-content: space-between;
     padding-top: 5px;
+    align-items: baseline;
   }
 
   .media-upload-icon, .poll-icon, .emoji-icon {
-    font-size: 26px;
+    font-size: 1.85em;
     line-height: 1.1;
     flex: 1;
     padding: 0 0.1em;
+    display: flex;
+    align-items: center;
 
     &.selected, &:hover {
       // needs to be specific to override icon default color
@@ -441,21 +443,17 @@
   // Order is not necessary but a good indicator
   .media-upload-icon {
     order: 1;
-    text-align: left;
+    justify-content: left;
   }
 
   .emoji-icon {
     order: 2;
-    text-align: center;
+    justify-content: center;
   }
 
   .poll-icon {
     order: 3;
-    text-align: right;
-  }
-
-  .poll-icon {
-    cursor: pointer;
+    justify-content: right;
   }
 
   .error {
@@ -489,10 +487,6 @@
     flex-direction: column;
   }
 
-  .btn {
-    cursor: pointer;
-  }
-
   .btn[disabled] {
     cursor: not-allowed;
   }
@@ -508,26 +502,20 @@
     display: flex;
     flex-direction: column;
     padding: 0.25em 0.5em 0.5em;
-    line-height:24px;
-  }
-
-  form textarea.form-cw {
-    line-height:16px;
-    resize: none;
-    overflow: hidden;
-    transition: min-height 200ms 100ms;
-    min-height: 1px;
+    line-height: 1.85;
   }
 
   .form-post-body {
-    height: 16px; // Only affects the empty-height
-    line-height: 16px;
-    resize: none;
+    // TODO: make a resizable textarea component?
+    box-sizing: content-box; // needed for easier computation of dynamic size
     overflow: hidden;
     transition: min-height 200ms 100ms;
-    padding-bottom: 1.75em;
-    min-height: 1px;
-    box-sizing: content-box;
+    // stock padding + 1 line of text (for counter)
+    padding-bottom: calc(var(--_padding) + var(--post-line-height) * 1em);
+    // two lines of text
+    height: calc(var(--post-line-height) * 1em);
+    min-height: calc(var(--post-line-height) * 1em);
+    resize: none;
 
     &.scrollable-form {
       overflow-y: auto;
@@ -551,10 +539,6 @@
     }
   }
 
-  .btn {
-    cursor: pointer;
-  }
-
   .btn[disabled] {
     cursor: not-allowed;
   }
@@ -571,7 +555,6 @@
 
   .drop-indicator {
     position: absolute;
-    z-index: 1;
     width: 100%;
     height: 100%;
     font-size: 5em;

+ 1 - 1
src/components/react_button/react_button.vue

@@ -101,7 +101,7 @@
       cursor: pointer;
 
       flex-basis: 20%;
-      line-height: 1.5em;
+      line-height: 1.5;
       align-content: center;
 
       &:hover {

+ 8 - 5
src/components/registration/registration.vue

@@ -283,7 +283,10 @@ $validations-cRed: #f04124;
   .container {
     display: flex;
     flex-direction: row;
-    //margin-bottom: 1em;
+
+    > * {
+      min-width: 0;
+    }
   }
 
   .terms-of-service {
@@ -306,8 +309,8 @@ $validations-cRed: #f04124;
   .form-group {
     display: flex;
     flex-direction: column;
-    padding: 0.3em 0.0em 0.3em;
-    line-height:24px;
+    padding: 0.3em 0;
+    line-height: 2;
     margin-bottom: 1em;
   }
 
@@ -327,7 +330,7 @@ $validations-cRed: #f04124;
     text-align: left;
 
     span {
-      font-size: 12px;
+      font-size: 0.85em;
     }
   }
 
@@ -353,7 +356,7 @@ $validations-cRed: #f04124;
 
   .btn {
     margin-top: 0.6em;
-    height: 28px;
+    height: 2em;
   }
 
   .error {

+ 1 - 1
src/components/remote_follow/remote_follow.vue

@@ -32,7 +32,7 @@
 
   .remote-button {
     width: 100%;
-    min-height: 28px;
+    min-height: 2em;
   }
 }
 </style>

+ 3 - 3
src/components/select/select.vue

@@ -39,10 +39,10 @@ label.Select {
     padding: 0 2em 0 .2em;
     font-family: sans-serif;
     font-family: var(--inputFont, sans-serif);
-    font-size: 14px;
+    font-size: 1em;
     width: 100%;
     z-index: 1;
-    height: 28px;
+    height: 2em;
     line-height: 16px;
   }
 
@@ -55,7 +55,7 @@ label.Select {
     width: 0.875em;
     color: $fallback--text;
     color: var(--inputText, $fallback--text);
-    line-height: 28px;
+    line-height: 2;
     z-index: 0;
     pointer-events: none;
   }

+ 13 - 1
src/components/settings_modal/settings_modal.scss

@@ -2,6 +2,18 @@
 .settings-modal {
   overflow: hidden;
 
+  .setting-list,
+  .option-list {
+    list-style-type: none;
+    padding-left: 2em;
+    li {
+      margin-bottom: 0.5em;
+    }
+    .suboptions {
+      margin-top: 0.3em
+    }
+  }
+
   &.peek {
     .settings-modal-panel {
       /* Explanation:
@@ -42,7 +54,7 @@
       overflow-y: hidden;
 
       .btn {
-        min-height: 28px;
+        min-height: 2em;
         min-width: 10em;
         padding: 0 2em;
       }

+ 7 - 16
src/components/settings_modal/settings_modal.vue

@@ -11,22 +11,13 @@
           {{ $t('settings.settings') }}
         </span>
         <transition name="fade">
-          <div v-if="currentSaveStateNotice">
-            <div
-              v-if="currentSaveStateNotice.error"
-              class="alert error"
-              @click.prevent
-            >
-              {{ $t('settings.saving_err') }}
-            </div>
-
-            <div
-              v-if="!currentSaveStateNotice.error"
-              class="alert transparent"
-              @click.prevent
-            >
-              {{ $t('settings.saving_ok') }}
-            </div>
+          <div
+            v-if="currentSaveStateNotice"
+            class="alert"
+            :class="{ transparent: !currentSaveStateNotice.error, error: currentSaveStateNotice.error}"
+            @click.prevent
+          >
+            {{ currentSaveStateNotice.error ? $t('settings.saving_err') : $t('settings.saving_ok') }}
           </div>
         </transition>
         <button

+ 5 - 0
src/components/settings_modal/tabs/general_tab.js

@@ -38,6 +38,11 @@ const GeneralTab = {
         value: mode,
         label: this.$t(`settings.mention_link_display_${mode}`)
       })),
+      thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({
+        key: mode,
+        value: mode,
+        label: this.$t(`settings.third_column_mode_${mode}`)
+      })),
       loopSilentAvailable:
       // Firefox
       Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||

+ 20 - 0
src/components/settings_modal/tabs/general_tab.vue

@@ -64,6 +64,26 @@
             {{ $t('settings.virtual_scrolling') }}
           </BooleanSetting>
         </li>
+        <li>
+          <BooleanSetting path="disableStickyHeaders">
+            {{ $t('settings.disable_sticky_headers') }}
+          </BooleanSetting>
+        </li>
+        <li>
+          <BooleanSetting path="showScrollbars">
+            {{ $t('settings.show_scrollbars') }}
+          </BooleanSetting>
+        </li>
+        <li>
+          <ChoiceSetting
+            v-if="user"
+            id="thirdColumnMode"
+            path="thirdColumnMode"
+            :options="thirdColumnModeOptions"
+          >
+            {{ $t('settings.third_column_mode') }}
+          </ChoiceSetting>
+        </li>
         <li>
           <BooleanSetting
             path="alwaysShowNewPostButton"

+ 1 - 1
src/components/settings_modal/tabs/mutes_and_blocks_tab.scss

@@ -8,7 +8,7 @@
     .bulk-actions {
         text-align: right;
         padding: 0 1em;
-        min-height: 28px;
+        min-height: 2em;
     }
 
     .bulk-action-button {

+ 1 - 1
src/components/settings_modal/tabs/profile_tab.scss

@@ -89,7 +89,7 @@
   &-bulk-actions {
     text-align: right;
     padding: 0 1em;
-    min-height: 28px;
+    min-height: 2em;
 
     button {
       width: 10em;

+ 1 - 18
src/components/settings_modal/tabs/theme_tab/theme_tab.scss

@@ -245,25 +245,8 @@
         border-color: var(--border, $fallback--border);
       }
 
-      .panel-heading {
-        .badge, .alert, .btn, .faint {
-          margin-left: 1em;
-          white-space: nowrap;
-        }
-        .faint {
-          text-overflow: ellipsis;
-          min-width: 2em;
-          overflow-x: hidden;
-        }
-        .flex-spacer {
-          flex: 1;
-        }
-      }
       .btn {
-        margin-left: 0;
-        padding: 0 1em;
         min-width: 3em;
-        min-height: 30px;
       }
     }
   }
@@ -342,7 +325,7 @@
 
     .btn {
       flex-grow: 1;
-      min-height: 28px;
+      min-height: 2em;
       min-width: 0;
       max-width: 10em;
       padding: 0;

+ 10 - 9
src/components/shout_panel/shout_panel.vue

@@ -57,7 +57,7 @@
   >
     <div class="panel panel-default">
       <div
-        class="panel-heading stub timeline-heading shout-heading"
+        class="panel-heading -stub timeline-heading shout-heading"
         @click.stop.prevent="togglePanel"
       >
         <div class="title">
@@ -79,17 +79,17 @@
 
 .floating-shout {
   position: fixed;
-  bottom: 0px;
+  bottom: 0.5em;
   z-index: 1000;
   max-width: 25em;
-}
 
-.floating-shout.left {
-  left: 0px;
-}
+  &.-left {
+    left: 0.5em;
+  }
 
-.floating-shout:not(.left) {
-  right: 0px;
+  &:not(.-left) {
+    right: 0.5em;
+  }
 }
 
 .shout-panel {
@@ -121,7 +121,7 @@
 
   .shout-message {
     display: flex;
-    padding: 0.2em 0.5em
+    padding: 0.2em 0.5em;
   }
 
   .shout-avatar {
@@ -137,6 +137,7 @@
 
   .shout-input {
     display: flex;
+
     textarea {
       flex: 1;
       margin: 0.6em;

+ 10 - 7
src/components/status/status.scss

@@ -42,6 +42,10 @@
     display: flex;
     padding: var(--status-margin, $status-margin);
 
+    > * {
+      min-width: 0;
+    }
+
     &.-repeat {
       padding-top: 0;
     }
@@ -78,7 +82,6 @@
 
   .status-username {
     white-space: nowrap;
-    font-size: 14px;
     overflow: hidden;
     max-width: 85%;
     font-weight: bold;
@@ -103,7 +106,7 @@
   .heading-name-row {
     display: flex;
     justify-content: space-between;
-    line-height: 18px;
+    line-height: 1.3;
 
     a {
       display: inline-block;
@@ -156,7 +159,7 @@
   & .heading-reply-row {
     position: relative;
     align-content: baseline;
-    font-size: 12px;
+    font-size: 0.85em;
     margin-top: 0.2em;
     line-height: 130%;
     max-width: 100%;
@@ -224,8 +227,8 @@
 
   .replies {
     margin-top: 0.25em;
-    line-height: 18px;
-    font-size: 12px;
+    line-height: 1.3;
+    font-size: 0.85em;
     display: flex;
     flex-wrap: wrap;
 
@@ -385,14 +388,14 @@
 
     .stat-title {
       color: var(--faint, $fallback--faint);
-      font-size: 12px;
+      font-size: 0.85em;
       text-transform: uppercase;
       position: relative;
     }
 
     .stat-number {
       font-weight: bolder;
-      font-size: 16px;
+      font-size: 1.1em;
       line-height: 1em;
     }
 

+ 1 - 1
src/components/status_body/status_body.scss

@@ -19,7 +19,7 @@
     overflow-wrap: break-word;
     word-wrap: break-word;
     word-break: break-word;
-    line-height: 1.4em;
+    line-height: var(--post-line-height);
   }
 
   .summary {

+ 4 - 4
src/components/still-image/still-image.vue

@@ -58,10 +58,10 @@
       zoom: var(--_still_image-label-scale, 1);
       content: 'gif';
       position: absolute;
-      line-height: 10px;
-      font-size: 10px;
-      top: 5px;
-      left: 5px;
+      line-height: 1;
+      font-size: 0.7em;
+      top: 0.5em;
+      left: 0.5em;
       background: rgba(127, 127, 127, 0.5);
       color: #fff;
       display: block;

+ 2 - 1
src/components/tab_switcher/tab_switcher.scss

@@ -25,8 +25,9 @@
         border-bottom-color: $fallback--border;
         border-bottom-color: var(--border, $fallback--border);
       }
+
       .tab-wrapper {
-        height: 28px;
+        height: 2em;
 
         &:not(.active)::after {
           left: 0;

+ 5 - 3
src/components/timeline/timeline.js

@@ -22,7 +22,8 @@ const Timeline = {
     'embedded',
     'count',
     'pinnedStatusIds',
-    'inProfile'
+    'inProfile',
+    'footerSlipgate' // reference to an element where we should put our footer
   ],
   data () {
     return {
@@ -60,11 +61,11 @@ const Timeline = {
       }
     },
     classes () {
-      let rootClasses = !this.embedded ? ['panel', 'panel-default'] : []
+      let rootClasses = !this.embedded ? ['panel', 'panel-default'] : ['-nonpanel']
       if (this.blockingClicks) rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
       return {
         root: rootClasses,
-        header: ['timeline-heading'].concat(!this.embedded ? ['panel-heading'] : []),
+        header: ['timeline-heading'].concat(!this.embedded ? ['panel-heading', '-sticky'] : []),
         body: ['timeline-body'].concat(!this.embedded ? ['panel-body'] : []),
         footer: ['timeline-footer'].concat(!this.embedded ? ['panel-footer'] : [])
       }
@@ -142,6 +143,7 @@ const Timeline = {
         this.$store.commit('showNewStatuses', { timeline: this.timelineName })
         this.paused = false
       }
+      window.scrollTo({ top: 0 })
     },
     fetchOlderStatuses: throttle(function () {
       const store = this.$store

+ 14 - 14
src/components/timeline/timeline.scss

@@ -9,23 +9,23 @@
     cursor: progress;
   }
 
-  .timeline-heading {
-    max-width: 100%;
-    flex-wrap: nowrap;
-    align-items: center;
-    position: relative;
+  .conversation-heading {
+    top: calc(var(--__panel-heading-height) * var(--currentPanelStack, 2));
+    z-index: 1;
+  }
 
-    .loadmore-button {
-      flex-shrink: 0;
+  &.-nonpanel {
+    .timeline-heading {
+      text-align: center;
+      line-height: 2.75em;
+      padding: 0 0.5em;
     }
 
-    .loadmore-text {
-      flex-shrink: 0;
-      line-height: 1em;
+    .timeline-heading {
+      .button-default, .alert {
+        line-height: 2em;
+        width: 100%;
+      }
     }
   }
-
-  .timeline-footer {
-    border: none;
-  }
 }

+ 34 - 32
src/components/timeline/timeline.vue

@@ -1,5 +1,5 @@
 <template>
-  <div :class="[classes.root, 'Timeline']">
+  <div :class="['Timeline', classes.root]">
     <div :class="classes.header">
       <TimelineMenu v-if="!embedded" />
       <button
@@ -10,7 +10,7 @@
         {{ loadButtonString }}
       </button>
       <div
-        v-else
+        v-else-if="!embedded"
         class="loadmore-text faint"
         @click.prevent
       >
@@ -46,37 +46,39 @@
       </div>
     </div>
     <div :class="classes.footer">
-      <div
-        v-if="count===0"
-        class="new-status-notification text-center faint"
-      >
-        {{ $t('timeline.no_statuses') }}
-      </div>
-      <div
-        v-else-if="bottomedOut"
-        class="new-status-notification text-center faint"
-      >
-        {{ $t('timeline.no_more_statuses') }}
-      </div>
-      <button
-        v-else-if="!timeline.loading"
-        class="button-unstyled -link -fullwidth"
-        @click.prevent="fetchOlderStatuses()"
-      >
-        <div class="new-status-notification text-center">
-          {{ $t('timeline.load_older') }}
+      <teleport :to="footerSlipgate" :disabled="!embedded || !footerSlipgate">
+        <div
+          v-if="count===0"
+          class="new-status-notification text-center faint"
+        >
+          {{ $t('timeline.no_statuses') }}
         </div>
-      </button>
-      <div
-        v-else
-        class="new-status-notification text-center"
-      >
-        <FAIcon
-          icon="circle-notch"
-          spin
-          size="lg"
-        />
-      </div>
+        <div
+          v-else-if="bottomedOut"
+          class="new-status-notification text-center faint"
+        >
+          {{ $t('timeline.no_more_statuses') }}
+        </div>
+        <button
+          v-else-if="!timeline.loading"
+          class="button-unstyled -link"
+          @click.prevent="fetchOlderStatuses()"
+        >
+          <div class="new-status-notification text-center">
+            {{ $t('timeline.load_older') }}
+          </div>
+        </button>
+        <div
+          v-else
+          class="new-status-notification text-center"
+        >
+          <FAIcon
+            icon="circle-notch"
+            spin
+            size="lg"
+          />
+        </div>
+      </teleport>
     </div>
   </div>
 </template>

+ 11 - 13
src/components/timeline/timeline_quick_settings.vue

@@ -12,8 +12,8 @@
             @click="replyVisibilityAll = true"
           >
             <span
-              class="menu-checkbox"
-              :class="{ 'menu-checkbox-radio': replyVisibilityAll }"
+              class="menu-checkbox -radio"
+              :class="{ 'menu-checkbox-checked': replyVisibilityAll }"
             />{{ $t('settings.reply_visibility_all') }}
           </button>
           <button
@@ -21,8 +21,8 @@
             @click="replyVisibilityFollowing = true"
           >
             <span
-              class="menu-checkbox"
-              :class="{ 'menu-checkbox-radio': replyVisibilityFollowing }"
+              class="menu-checkbox -radio"
+              :class="{ 'menu-checkbox-checked': replyVisibilityFollowing }"
             />{{ $t('settings.reply_visibility_following_short') }}
           </button>
           <button
@@ -30,8 +30,8 @@
             @click="replyVisibilitySelf = true"
           >
             <span
-              class="menu-checkbox"
-              :class="{ 'menu-checkbox-radio': replyVisibilitySelf }"
+              class="menu-checkbox -radio"
+              :class="{ 'menu-checkbox-checked': replyVisibilitySelf }"
             />{{ $t('settings.reply_visibility_self_short') }}
           </button>
           <div
@@ -93,18 +93,16 @@
 <style lang="scss">
 
 .TimelineQuickSettings {
-  align-self: stretch;
 
   > button {
-    font-size: 1.2em;
-    padding-left: 0.7em;
-    padding-right: 0.2em;
     line-height: 100%;
     height: 100%;
-  }
+    width: var(--__panel-heading-height-inner);
+    text-align: center;
 
-  .dropdown-item {
-    margin: 0;
+    svg {
+      font-size: 1.2em;
+    }
   }
 }
 

+ 4 - 0
src/components/timeline_menu/timeline_menu.vue

@@ -43,6 +43,10 @@
   min-width: 0;
   width: 24rem;
 
+  .popover-trigger-button {
+    vertical-align: bottom;
+  }
+
   .timeline-menu-popover-wrap {
     overflow: hidden;
     // Match panel heading padding to line up menu with bottom of heading

+ 323 - 0
src/components/user_card/user_card.scss

@@ -0,0 +1,323 @@
+@import '../../_variables.scss';
+
+.user-card {
+  position: relative;
+  z-index: 1;
+
+  &:hover {
+    --_still-image-img-visibility: visible;
+    --_still-image-canvas-visibility: hidden;
+    --_still-image-label-visibility: hidden;
+  }
+
+  .panel-heading {
+    padding: .5em 0;
+    text-align: center;
+    box-shadow: none;
+    background: transparent;
+    flex-direction: column;
+    align-items: stretch;
+    // create new stacking context
+    position: relative;
+  }
+
+  .panel-body {
+    word-wrap: break-word;
+    border-bottom-right-radius: inherit;
+    border-bottom-left-radius: inherit;
+    // create new stacking context
+    position: relative;
+  }
+
+  .background-image {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    mask: linear-gradient(to top, white, transparent) bottom no-repeat,
+          linear-gradient(to top, white, white);
+    // Autoprefixer seem to ignore this one, and also syntax is different
+    -webkit-mask-composite: xor;
+    mask-composite: exclude;
+    background-size: cover;
+    mask-size: 100% 60%;
+    border-top-left-radius: calc(var(--panelRadius) - 1px);
+    border-top-right-radius: calc(var(--panelRadius) - 1px);
+    background-color: var(--profileBg);
+    z-index: -2;
+
+    &.hide-bio {
+      mask-size: 100% 40px;
+    }
+  }
+
+  &-bio {
+    text-align: center;
+    display: block;
+    line-height: 1.3;
+    padding: 1em;
+    margin: 0;
+
+    a {
+      color: $fallback--link;
+      color: var(--postLink, $fallback--link);
+    }
+
+    img {
+      object-fit: contain;
+      vertical-align: middle;
+      max-width: 100%;
+      max-height: 400px;
+    }
+  }
+
+  // Modifiers
+
+  &-rounded-t {
+    border-top-left-radius: $fallback--panelRadius;
+    border-top-left-radius: var(--panelRadius, $fallback--panelRadius);
+    border-top-right-radius: $fallback--panelRadius;
+    border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
+  }
+
+  &-rounded {
+    border-radius: $fallback--panelRadius;
+    border-radius: var(--panelRadius, $fallback--panelRadius);
+  }
+
+  &-bordered {
+    border-width: 1px;
+    border-style: solid;
+    border-color: $fallback--border;
+    border-color: var(--border, $fallback--border);
+  }
+}
+
+.user-info {
+  color: $fallback--lightText;
+  color: var(--lightText, $fallback--lightText);
+  padding: 0 26px;
+
+  .container {
+    min-width: 0;
+    padding: 16px 0 6px;
+    display: flex;
+    align-items: flex-start;
+    max-height: 56px;
+
+    > * {
+      min-width: 0;
+    }
+
+    .Avatar {
+      --_avatarShadowBox: var(--avatarShadow);
+      --_avatarShadowFilter: var(--avatarShadowFilter);
+      --_avatarShadowInset: var(--avatarShadowInset);
+
+      flex: 1 0 100%;
+      width: 56px;
+      height: 56px;
+      object-fit: cover;
+    }
+  }
+
+  &-avatar-link {
+    position: relative;
+    cursor: pointer;
+
+    &-overlay {
+      position: absolute;
+      left: 0;
+      top: 0;
+      right: 0;
+      bottom: 0;
+      background-color: rgba(0, 0, 0, 0.3);
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      border-radius: $fallback--avatarRadius;
+      border-radius: var(--avatarRadius, $fallback--avatarRadius);
+      opacity: 0;
+      transition: opacity .2s ease;
+
+      svg {
+        color: #FFF;
+      }
+    }
+
+    &:hover &-overlay {
+      opacity: 1;
+    }
+  }
+
+  .external-link-button, .edit-profile-button {
+    cursor: pointer;
+    width: 2.5em;
+    text-align: center;
+    margin: -0.5em 0;
+    padding: 0.5em 0;
+
+    &:not(:hover) .icon {
+      color: $fallback--lightText;
+      color: var(--lightText, $fallback--lightText);
+    }
+  }
+
+  .user-summary {
+    display: block;
+    margin-left: 0.6em;
+    text-align: left;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    flex: 1 1 0;
+    // This is so that text doesn't get overlapped by avatar's shadow if it has
+    // big one
+    z-index: 1;
+    line-height: 2em;
+
+    --emoji-size: 1.7em;
+
+    .top-line,
+    .bottom-line {
+      display: flex;
+    }
+  }
+
+  .user-name {
+    text-overflow: ellipsis;
+    overflow: hidden;
+    flex: 1 1 auto;
+    margin-right: 1em;
+    font-size: 1.1em;
+  }
+
+  .bottom-line {
+    font-weight: light;
+    font-size: 1.1em;
+    align-items: baseline;
+
+    .lock-icon {
+      margin-left: 0.5em;
+    }
+
+    .user-screen-name {
+      min-width: 1px;
+      flex: 0 1 auto;
+      text-overflow: ellipsis;
+      overflow: hidden;
+      color: $fallback--lightText;
+      color: var(--lightText, $fallback--lightText);
+    }
+
+    .dailyAvg {
+      min-width: 1px;
+      flex: 0 0 auto;
+      margin-left: 1em;
+      font-size: 0.7em;
+      color: $fallback--text;
+      color: var(--text, $fallback--text);
+    }
+
+    .user-role {
+      flex: none;
+      color: $fallback--text;
+      color: var(--alertNeutralText, $fallback--text);
+      background-color: $fallback--fg;
+      background-color: var(--alertNeutral, $fallback--fg);
+    }
+  }
+
+  .user-meta {
+    margin-bottom: .15em;
+    display: flex;
+    align-items: baseline;
+    line-height: 22px;
+    flex-wrap: wrap;
+
+    .following {
+      flex: 1 0 auto;
+      margin: 0;
+      margin-bottom: .25em;
+      text-align: left;
+    }
+
+    .highlighter {
+      flex: 0 1 auto;
+      display: flex;
+      flex-wrap: wrap;
+      margin-right: -.5em;
+      align-self: start;
+
+      .userHighlightCl {
+        padding: 2px 10px;
+        flex: 1 0 auto;
+      }
+
+      .userHighlightSel {
+        padding-top: 0;
+        padding-bottom: 0;
+        flex: 1 0 auto;
+      }
+
+      .userHighlightText {
+        width: 70px;
+        flex: 1 0 auto;
+      }
+
+      .userHighlightCl,
+      .userHighlightText,
+      .userHighlightSel {
+        vertical-align: top;
+        margin-right: .5em;
+        margin-bottom: .25em;
+      }
+    }
+  }
+  .user-interactions {
+    position: relative;
+    display: flex;
+    flex-flow: row wrap;
+    margin-right: -.75em;
+
+    > * {
+      margin: 0 .75em .6em 0;
+      white-space: nowrap;
+      min-width: 95px;
+    }
+
+    button {
+      margin: 0;
+    }
+  }
+}
+
+.sidebar .edit-profile-button {
+  display: none;
+}
+
+.user-counts {
+  display: flex;
+  line-height:16px;
+  padding: .5em 1.5em 0em 1.5em;
+  text-align: center;
+  justify-content: space-between;
+  color: $fallback--lightText;
+  color: var(--lightText, $fallback--lightText);
+  flex-wrap: wrap;
+}
+
+.user-count {
+  flex: 1 0 auto;
+  padding: .5em 0 .5em 0;
+  margin: 0 .5em;
+
+  h5 {
+    font-size:1em;
+    font-weight: bolder;
+    margin: 0 0 0.25em;
+  }
+  a {
+    text-decoration: none;
+  }
+}

+ 2 - 318
src/components/user_card/user_card.vue

@@ -8,7 +8,7 @@
       :style="style"
       class="background-image"
     />
-    <div class="panel-heading">
+    <div class="panel-heading -flexible-height">
       <div class="user-info">
         <div class="container">
           <a
@@ -284,320 +284,4 @@
 
 <script src="./user_card.js"></script>
 
-<style lang="scss">
-@import '../../_variables.scss';
-
-.user-card {
-  position: relative;
-
-  &:hover {
-    --_still-image-img-visibility: visible;
-    --_still-image-canvas-visibility: hidden;
-    --_still-image-label-visibility: hidden;
-  }
-
-  .panel-heading {
-    padding: .5em 0;
-    text-align: center;
-    box-shadow: none;
-    background: transparent;
-    flex-direction: column;
-    align-items: stretch;
-    // create new stacking context
-    position: relative;
-  }
-
-  .panel-body {
-    word-wrap: break-word;
-    border-bottom-right-radius: inherit;
-    border-bottom-left-radius: inherit;
-    // create new stacking context
-    position: relative;
-  }
-
-  .background-image {
-    position: absolute;
-    top: 0;
-    left: 0;
-    right: 0;
-    bottom: 0;
-    mask: linear-gradient(to top, white, transparent) bottom no-repeat,
-          linear-gradient(to top, white, white);
-    // Autoprefixed seem to ignore this one, and also syntax is different
-                                                 -webkit-mask-composite: xor;
-    mask-composite: exclude;
-    background-size: cover;
-    mask-size: 100% 60%;
-    border-top-left-radius: calc(var(--panelRadius) - 1px);
-    border-top-right-radius: calc(var(--panelRadius) - 1px);
-    background-color: var(--profileBg);
-
-    &.hide-bio {
-      mask-size: 100% 40px;
-    }
-  }
-
-  &-bio {
-    text-align: center;
-    display: block;
-    line-height: 18px;
-    padding: 1em;
-    margin: 0;
-
-    a {
-      color: $fallback--link;
-      color: var(--postLink, $fallback--link);
-    }
-
-    img {
-      object-fit: contain;
-      vertical-align: middle;
-      max-width: 100%;
-      max-height: 400px;
-    }
-  }
-
-  // Modifiers
-
-  &-rounded-t {
-    border-top-left-radius: $fallback--panelRadius;
-    border-top-left-radius: var(--panelRadius, $fallback--panelRadius);
-    border-top-right-radius: $fallback--panelRadius;
-    border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
-  }
-
-  &-rounded {
-    border-radius: $fallback--panelRadius;
-    border-radius: var(--panelRadius, $fallback--panelRadius);
-  }
-
-  &-bordered {
-    border-width: 1px;
-    border-style: solid;
-    border-color: $fallback--border;
-    border-color: var(--border, $fallback--border);
-  }
-}
-
-.user-info {
-  color: $fallback--lightText;
-  color: var(--lightText, $fallback--lightText);
-  padding: 0 26px;
-
-  .container {
-    padding: 16px 0 6px;
-    display: flex;
-    align-items: flex-start;
-    max-height: 56px;
-
-    .Avatar {
-      --_avatarShadowBox: var(--avatarShadow);
-      --_avatarShadowFilter: var(--avatarShadowFilter);
-      --_avatarShadowInset: var(--avatarShadowInset);
-
-      flex: 1 0 100%;
-      width: 56px;
-      height: 56px;
-      object-fit: cover;
-    }
-  }
-
-  &-avatar-link {
-    position: relative;
-    cursor: pointer;
-
-    &-overlay {
-      position: absolute;
-      left: 0;
-      top: 0;
-      right: 0;
-      bottom: 0;
-      background-color: rgba(0, 0, 0, 0.3);
-      display: flex;
-      justify-content: center;
-      align-items: center;
-      border-radius: $fallback--avatarRadius;
-      border-radius: var(--avatarRadius, $fallback--avatarRadius);
-      opacity: 0;
-      transition: opacity .2s ease;
-
-      svg {
-        color: #FFF;
-      }
-    }
-
-    &:hover &-overlay {
-      opacity: 1;
-    }
-  }
-
-  .external-link-button, .edit-profile-button {
-    cursor: pointer;
-    width: 2.5em;
-    text-align: center;
-    margin: -0.5em 0;
-    padding: 0.5em 0;
-
-    &:not(:hover) .icon {
-      color: $fallback--lightText;
-      color: var(--lightText, $fallback--lightText);
-    }
-  }
-
-  .user-summary {
-    display: block;
-    margin-left: 0.6em;
-    text-align: left;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-    flex: 1 1 0;
-    // This is so that text doesn't get overlapped by avatar's shadow if it has
-    // big one
-    z-index: 1;
-
-    .top-line {
-      display: flex;
-    }
-  }
-
-  .user-name {
-    text-overflow: ellipsis;
-    overflow: hidden;
-    flex: 1 1 auto;
-    margin-right: 1em;
-    font-size: 15px;
-
-    --emoji-size: 14px;
-  }
-
-  .bottom-line {
-    display: flex;
-    font-weight: light;
-    font-size: 15px;
-
-    .lock-icon {
-      margin-left: 0.5em;
-    }
-
-    .user-screen-name {
-      min-width: 1px;
-      flex: 0 1 auto;
-      text-overflow: ellipsis;
-      overflow: hidden;
-      color: $fallback--lightText;
-      color: var(--lightText, $fallback--lightText);
-    }
-
-    .dailyAvg {
-      min-width: 1px;
-      flex: 0 0 auto;
-      margin-left: 1em;
-      font-size: 0.7em;
-      color: $fallback--text;
-      color: var(--text, $fallback--text);
-    }
-
-    .user-role {
-      flex: none;
-      color: $fallback--text;
-      color: var(--alertNeutralText, $fallback--text);
-      background-color: $fallback--fg;
-      background-color: var(--alertNeutral, $fallback--fg);
-    }
-  }
-
-  .user-meta {
-    margin-bottom: .15em;
-    display: flex;
-    align-items: baseline;
-    font-size: 14px;
-    line-height: 22px;
-    flex-wrap: wrap;
-
-    .following {
-      flex: 1 0 auto;
-      margin: 0;
-      margin-bottom: .25em;
-      text-align: left;
-    }
-
-    .highlighter {
-      flex: 0 1 auto;
-      display: flex;
-      flex-wrap: wrap;
-      margin-right: -.5em;
-      align-self: start;
-
-      .userHighlightCl {
-        padding: 2px 10px;
-        flex: 1 0 auto;
-      }
-
-      .userHighlightSel {
-        padding-top: 0;
-        padding-bottom: 0;
-        flex: 1 0 auto;
-      }
-
-      .userHighlightText {
-        width: 70px;
-        flex: 1 0 auto;
-      }
-
-      .userHighlightCl,
-      .userHighlightText,
-      .userHighlightSel {
-        vertical-align: top;
-        margin-right: .5em;
-        margin-bottom: .25em;
-      }
-    }
-  }
-  .user-interactions {
-    position: relative;
-    display: flex;
-    flex-flow: row wrap;
-    margin-right: -.75em;
-
-    > * {
-      margin: 0 .75em .6em 0;
-      white-space: nowrap;
-      min-width: 95px;
-    }
-
-    button {
-      margin: 0;
-    }
-  }
-}
-
-.sidebar .edit-profile-button {
-  display: none;
-}
-
-.user-counts {
-  display: flex;
-  line-height:16px;
-  padding: .5em 1.5em 0em 1.5em;
-  text-align: center;
-  justify-content: space-between;
-  color: $fallback--lightText;
-  color: var(--lightText, $fallback--lightText);
-  flex-wrap: wrap;
-}
-
-.user-count {
-  flex: 1 0 auto;
-  padding: .5em 0 .5em 0;
-  margin: 0 .5em;
-
-  h5 {
-    font-size:1em;
-    font-weight: bolder;
-    margin: 0 0 0.25em;
-  }
-  a {
-    text-decoration: none;
-  }
-}
-</style>
+<style lang="scss" src="./user_card.scss" />

+ 1 - 1
src/components/user_list_popover/user_list_popover.vue

@@ -73,7 +73,7 @@
     }
 
     .user-list-screen-name {
-      font-size: 9px;
+      font-size: 0.65em;
     }
   }
 }

+ 1 - 0
src/components/user_panel/user_panel.vue

@@ -24,5 +24,6 @@
 <style lang="scss">
 .user-panel .signed-in {
   overflow: visible;
+  z-index: 10;
 }
 </style>

+ 5 - 1
src/components/user_profile/user_profile.js

@@ -39,7 +39,8 @@ const UserProfile = {
     return {
       error: false,
       userId: null,
-      tab: defaultTabKey
+      tab: defaultTabKey,
+      footerRef: null
     }
   },
   created () {
@@ -78,6 +79,9 @@ const UserProfile = {
     }
   },
   methods: {
+    setFooterRef (el) {
+      this.footerRef = el
+    },
     load (userNameOrId) {
       const startFetchingTimeline = (timeline, userId) => {
         // Clear timeline only if load another user's profile

+ 8 - 19
src/components/user_profile/user_profile.vue

@@ -56,6 +56,7 @@
           :user-id="userId"
           :pinned-status-ids="user.pinnedStatusIds"
           :in-profile="true"
+          :footerSlipgate="footerRef"
         />
         <div
           v-if="followsTabVisible"
@@ -94,6 +95,7 @@
           :timeline="media"
           :user-id="userId"
           :in-profile="true"
+          :footerSlipgate="footerRef"
         />
         <Timeline
           v-if="isUs"
@@ -105,8 +107,10 @@
           timeline-name="favorites"
           :timeline="favorites"
           :in-profile="true"
+          :footerSlipgate="footerRef"
         />
       </tab-switcher>
+      <div class="panel-footer" :ref="setFooterRef"></div>
     </div>
     <div
       v-else
@@ -138,6 +142,9 @@
   flex: 2;
   flex-basis: 500px;
 
+  // No sticky header on user profile
+  --currentPanelStack: 1;
+
   .user-profile-fields {
     margin: 0 0.5em;
 
@@ -176,7 +183,7 @@
       }
 
       .user-profile-field-name, .user-profile-field-value {
-        line-height: 18px;
+        line-height: 1.3;
         text-overflow: ellipsis;
         white-space: nowrap;
         overflow: hidden;
@@ -192,24 +199,6 @@
     align-items: middle;
     padding: 2em;
   }
-
-  .timeline-heading {
-    display: flex;
-    justify-content: center;
-
-    .loadmore-button, .alert {
-      flex: 1;
-    }
-
-    .loadmore-button {
-      height: 28px;
-      margin: 10px .6em;
-    }
-
-    .title, .loadmore-text {
-      display: none
-    }
-  }
 }
 .user-profile-placeholder {
   .panel-body {

+ 1 - 12
src/components/user_reporting_modal/user_reporting_modal.vue

@@ -76,17 +76,6 @@
   min-height: 20vh;
   max-height: 80vh;
 
-  .panel-heading {
-    .title {
-      text-align: center;
-      // TODO: Consider making these as default of panel
-      flex: 1;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-  }
-
   .panel-body {
     display: flex;
     flex-direction: column-reverse;
@@ -98,7 +87,7 @@
 
   &-left {
     padding: 1.1em 0.7em 0.7em;
-    line-height: 1.4em;
+    line-height: var(--post-line-height);
     box-sizing: border-box;
 
     > div {

+ 1 - 1
src/hocs/with_load_more/with_load_more.scss

@@ -10,7 +10,7 @@
     border-top-color: var(--border, $fallback--border);
 
     .error {
-      font-size: 14px;
+      font-size: 1rem;
     }
 
     a {

+ 1 - 1
src/hocs/with_subscription/with_subscription.scss

@@ -4,7 +4,7 @@
     text-align: center;
 
     .error {
-      font-size: 14px;
+      font-size: 1rem;
     }
   }
 }

+ 7 - 1
src/i18n/en.json

@@ -370,7 +370,7 @@
     "max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)",
     "hide_isp": "Hide instance-specific panel",
     "hide_shoutbox": "Hide instance shoutbox",
-    "right_sidebar": "Show sidebar on the right side",
+    "right_sidebar": "Reverse order of columns",
     "always_show_post_button": "Always show floating New Post button",
     "hide_wallpaper": "Hide instance wallpaper",
     "preload_images": "Preload images",
@@ -485,6 +485,12 @@
     "subject_line_noop": "Do not copy",
     "conversation_display": "Conversation display style",
     "conversation_display_tree": "Tree-style",
+    "disable_sticky_headers": "Don't stick column headers to top of the screen",
+    "show_scrollbars": "Show side column's scrollbars",
+    "third_column_mode": "When there's enough space, show third column containing",
+    "third_column_mode_none": "Don't show third column at all",
+    "third_column_mode_notifications": "Notifications column",
+    "third_column_mode_postform": "Main post form and navigation",
     "tree_advanced": "Allow more flexible navigation in tree view",
     "tree_fade_ancestors": "Display ancestors of the current status in faint text",
     "conversation_display_linear": "Linear-style",

+ 6 - 0
src/modules/config.js

@@ -50,6 +50,7 @@ export const defaultState = {
   pauseOnUnfocused: true,
   stopGifs: true,
   replyVisibility: 'all',
+  thirdColumnMode: 'notifications',
   notificationVisibility: {
     follows: true,
     mentions: true,
@@ -78,6 +79,8 @@ export const defaultState = {
   playVideosInModal: false,
   useOneClickNsfw: false,
   useContainFit: true,
+  disableStickyHeaders: false,
+  showScrollbars: false,
   greentext: undefined, // instance default
   useAtIcon: undefined, // instance default
   mentionLinkDisplay: undefined, // instance default
@@ -169,6 +172,9 @@ const config = {
           messages.setLanguage(this.getters.i18n, value)
           Cookies.set(BACKEND_LANGUAGE_COOKIE_NAME, localeService.internalToBackendLocale(value))
           break
+        case 'thirdColumnMode':
+          dispatch('setLayoutWidth', undefined)
+          break
       }
     }
   }

+ 24 - 6
src/modules/interface.js

@@ -13,7 +13,7 @@ const defaultState = {
       window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')
     )
   },
-  mobileLayout: false,
+  layoutType: 'normal',
   globalNotices: [],
   layoutHeight: 0,
   lastTimeline: null
@@ -36,8 +36,8 @@ const interfaceMod = {
     setNotificationPermission (state, permission) {
       state.notificationPermission = permission
     },
-    setMobileLayout (state, value) {
-      state.mobileLayout = value
+    setLayoutType (state, value) {
+      state.layoutType = value
     },
     closeSettingsModal (state) {
       state.settingsModalState = 'hidden'
@@ -72,6 +72,9 @@ const interfaceMod = {
     setLayoutHeight (state, value) {
       state.layoutHeight = value
     },
+    setLayoutWidth (state, value) {
+      state.layoutWidth = value
+    },
     setLastTimeline (state, value) {
       state.lastTimeline = value
     }
@@ -86,9 +89,6 @@ const interfaceMod = {
     setNotificationPermission ({ commit }, permission) {
       commit('setNotificationPermission', permission)
     },
-    setMobileLayout ({ commit }, value) {
-      commit('setMobileLayout', value)
-    },
     closeSettingsModal ({ commit }) {
       commit('closeSettingsModal')
     },
@@ -133,6 +133,24 @@ const interfaceMod = {
     setLayoutHeight ({ commit }, value) {
       commit('setLayoutHeight', value)
     },
+    // value is optional, assuming it was cached prior
+    setLayoutWidth ({ commit, state, rootGetters, rootState }, value) {
+      let width = value
+      if (value !== undefined) {
+        commit('setLayoutWidth', value)
+      } else {
+        width = state.layoutWidth
+      }
+      const mobileLayout = width <= 800
+      const normalOrMobile = mobileLayout ? 'mobile' : 'normal'
+      const { thirdColumnMode } = rootGetters.mergedConfig
+      if (thirdColumnMode === 'none' || !rootState.users.currentUser) {
+        commit('setLayoutType', normalOrMobile)
+      } else {
+        const wideLayout = width >= 1300
+        commit('setLayoutType', wideLayout ? 'wide' : normalOrMobile)
+      }
+    },
     setLastTimeline ({ commit }, value) {
       commit('setLastTimeline', value)
     }

+ 6 - 0
src/modules/users.js

@@ -1,4 +1,5 @@
 import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
+import { windowWidth, windowHeight } from '../services/window_utils/window_utils'
 import oauthApi from '../services/new_api/oauth.js'
 import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'lodash'
 import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
@@ -506,6 +507,8 @@ const users = {
           store.commit('resetStatuses')
           store.dispatch('resetChats')
           store.dispatch('setLastTimeline', 'public-timeline')
+          store.dispatch('setLayoutWidth', windowWidth())
+          store.dispatch('setLayoutHeight', windowHeight())
         })
     },
     loginUser (store, accessToken) {
@@ -566,6 +569,9 @@ const users = {
               // Get user mutes
               store.dispatch('fetchMutes')
 
+              store.dispatch('setLayoutWidth', windowWidth())
+              store.dispatch('setLayoutHeight', windowHeight())
+
               // Fetch our friends
               store.rootState.api.backendInteractor.fetchFriends({ id: user.id })
                 .then((friends) => commit('addNewUsers', friends))

+ 198 - 0
src/panel.scss

@@ -0,0 +1,198 @@
+.panel {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  background-color: $fallback--bg;
+  background-color: var(--bg, $fallback--bg);
+
+  &::after,
+  & {
+    border-radius: $fallback--panelRadius;
+    border-radius: var(--panelRadius, $fallback--panelRadius);
+  }
+
+  &::after {
+    content: '';
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    z-index: 5;
+    box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
+    box-shadow: var(--panelShadow);
+    pointer-events: none;
+  }
+}
+
+.panel-body {
+  padding: var(--panel-body-padding, 0);
+
+  &:empty::before {
+    content: "¯\\_(ツ)_/¯"; // Could use words but it'd require translations
+    display: block;
+    margin: 1em;
+    text-align: center;
+  }
+
+  > p {
+    line-height: 1.3;
+    padding: 1em;
+    margin: 0;
+  }
+}
+
+.panel-heading,
+.panel-footer {
+  --panel-heading-height-padding: 0.6em;
+  --__panel-heading-height: 3.2em;
+  --__panel-heading-height-inner: calc(var(--__panel-heading-height) - 2 * var(--panel-heading-height-padding));
+
+  position: relative;
+  box-sizing: border-box;
+  display: grid;
+  grid-auto-flow: column;
+  grid-template-columns: minmax(50%, 1fr);
+  grid-auto-columns: auto;
+  grid-column-gap: 0.5em;
+  flex: none;
+  background-size: cover;
+  padding: 0.6em;
+  height: var(--__panel-heading-height);
+  line-height: var(--__panel-heading-height-inner);
+  z-index: 4;
+
+  &.-flexible-height {
+    --__panel-heading-height: auto;
+
+    &::after,
+    &::before {
+      display: none;
+    }
+  }
+
+  &.-stub {
+    &,
+    &::after {
+      border-radius: $fallback--panelRadius;
+      border-radius: var(--panelRadius, $fallback--panelRadius);
+    }
+  }
+
+  &.-sticky {
+    position: sticky;
+    top: var(--navbar-height);
+  }
+
+  &::after,
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    right: 0;
+    left: 0;
+    pointer-events: none;
+  }
+
+  .title {
+    font-size: 1.3em;
+  }
+
+  .alert {
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    overflow-x: hidden;
+  }
+
+  &:not(.-flexible-height) {
+    > .button-default,
+    > .alert {
+      height: var(--__panel-heading-height-inner);
+      min-height: 0;
+      box-sizing: border-box;
+      margin: 0;
+      min-width: 1px;
+      padding-top: 0;
+      padding-bottom: 0;
+      align-self: stretch;
+    }
+  }
+}
+
+// TODO Should refactor panels into separate component and utilize slots
+
+.panel-heading {
+  border-radius: $fallback--panelRadius $fallback--panelRadius 0 0;
+  border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0;
+  border-width: 0 0 1px 0;
+  align-items: start;
+  // panel theme
+  color: var(--panelText);
+  background-color: $fallback--bg;
+  background-color: var(--bg, $fallback--bg);
+
+  &::after {
+    background-color: $fallback--fg;
+    background-color: var(--panel, $fallback--fg);
+    z-index: -2;
+    border-radius: $fallback--panelRadius $fallback--panelRadius 0 0;
+    border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0;
+    box-shadow: var(--panelHeaderShadow);
+  }
+
+  a,
+  .-link {
+    color: $fallback--link;
+    color: var(--panelLink, $fallback--link);
+  }
+
+  .faint {
+    background-color: transparent;
+    color: $fallback--faint;
+    color: var(--panelFaint, $fallback--faint);
+  }
+
+  .faint-link {
+    color: $fallback--faint;
+    color: var(--faintLink, $fallback--faint);
+  }
+
+  &:not(.-flexible-height) {
+    > .button-default {
+      flex-shrink: 0;
+
+      &,
+      i[class*=icon-] {
+        color: $fallback--text;
+        color: var(--btnPanelText, $fallback--text);
+      }
+
+      &:active {
+        background-color: $fallback--fg;
+        background-color: var(--btnPressedPanel, $fallback--fg);
+        color: $fallback--text;
+        color: var(--btnPressedPanelText, $fallback--text);
+      }
+
+      &:disabled {
+        color: $fallback--text;
+        color: var(--btnDisabledPanelText, $fallback--text);
+      }
+
+      &.toggled {
+        color: $fallback--text;
+        color: var(--btnToggledPanelText, $fallback--text);
+      }
+    }
+  }
+}
+
+.panel-footer {
+  border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
+  border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
+  align-items: center;
+  border-width: 1px 0 0 0;
+  border-style: solid;
+  border-color: var(--border, $fallback--border);
+}

+ 1 - 1
src/services/offset_finder/offset_finder.service.js

@@ -9,7 +9,7 @@ export const findOffset = (child, parent, { top = 0, left = 0 } = {}, ignorePadd
     result.left += ignorePadding ? 0 : leftPadding
   }
 
-  if (child.offsetParent && (parent === window || parent.contains(child.offsetParent) || parent === child.offsetParent)) {
+  if (child.offsetParent && window.getComputedStyle(child.offsetParent).position !== 'sticky' && (parent === window || parent.contains(child.offsetParent) || parent === child.offsetParent)) {
     return findOffset(child.offsetParent, parent, result, false)
   } else {
     if (parent !== window) {

+ 4 - 4
src/services/style_setter/style_setter.js

@@ -13,10 +13,10 @@ export const applyTheme = (input) => {
   const styleSheet = styleEl.sheet
 
   styleSheet.toString()
-  styleSheet.insertRule(`body { ${rules.radii} }`, 'index-max')
-  styleSheet.insertRule(`body { ${rules.colors} }`, 'index-max')
-  styleSheet.insertRule(`body { ${rules.shadows} }`, 'index-max')
-  styleSheet.insertRule(`body { ${rules.fonts} }`, 'index-max')
+  styleSheet.insertRule(`:root { ${rules.radii} }`, 'index-max')
+  styleSheet.insertRule(`:root { ${rules.colors} }`, 'index-max')
+  styleSheet.insertRule(`:root { ${rules.shadows} }`, 'index-max')
+  styleSheet.insertRule(`:root { ${rules.fonts} }`, 'index-max')
   body.classList.remove('hidden')
 }