`, and ``.
+@font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
+@font-family-base: @font-family-sans-serif;
+
+@font-size-base: 14px;
+@font-size-large: ceil((@font-size-base * 1.25)); // ~18px
+@font-size-small: ceil((@font-size-base * 0.85)); // ~12px
+
+@font-size-h1: floor((@font-size-base * 2.6)); // ~36px
+@font-size-h2: floor((@font-size-base * 2.15)); // ~30px
+@font-size-h3: ceil((@font-size-base * 1.7)); // ~24px
+@font-size-h4: ceil((@font-size-base * 1.25)); // ~18px
+@font-size-h5: @font-size-base;
+@font-size-h6: ceil((@font-size-base * 0.85)); // ~12px
+
+//** Unit-less `line-height` for use in components like buttons.
+@line-height-base: 1.428571429; // 20/14
+//** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
+@line-height-computed: floor((@font-size-base * @line-height-base)); // ~20px
+
+//** By default, this inherits from the ``.
+@headings-font-family: inherit;
+@headings-font-weight: 500;
+@headings-line-height: 1.1;
+@headings-color: inherit;
+
+
+//== Iconography
+//
+//## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower.
+
+//** Load fonts from this directory.
+@icon-font-path: "../fonts/";
+//** File name for all font files.
+@icon-font-name: "glyphicons-halflings-regular";
+//** Element ID within SVG icon file.
+@icon-font-svg-id: "glyphicons_halflingsregular";
+
+
+//== Components
+//
+//## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
+
+@padding-base-vertical: 6px;
+@padding-base-horizontal: 12px;
+
+@padding-large-vertical: 10px;
+@padding-large-horizontal: 16px;
+
+@padding-small-vertical: 5px;
+@padding-small-horizontal: 10px;
+
+@padding-xs-vertical: 1px;
+@padding-xs-horizontal: 5px;
+
+@line-height-large: 1.33;
+@line-height-small: 1.5;
+
+@border-radius-base: 4px;
+@border-radius-large: 6px;
+@border-radius-small: 3px;
+
+//** Global color for active items (e.g., navs or dropdowns).
+@component-active-color: #fff;
+//** Global background color for active items (e.g., navs or dropdowns).
+@component-active-bg: @brand-primary;
+
+//** Width of the `border` for generating carets that indicator dropdowns.
+@caret-width-base: 4px;
+//** Carets increase slightly in size for larger components.
+@caret-width-large: 5px;
+
+
+//== Tables
+//
+//## Customizes the `.table` component with basic values, each used across all table variations.
+
+//** Padding for ``s and ` | `s.
+@table-cell-padding: 8px;
+//** Padding for cells in `.table-condensed`.
+@table-condensed-cell-padding: 5px;
+
+//** Default background color used for all tables.
+@table-bg: transparent;
+//** Background color used for `.table-striped`.
+@table-bg-accent: #f9f9f9;
+//** Background color used for `.table-hover`.
+@table-bg-hover: #f5f5f5;
+@table-bg-active: @table-bg-hover;
+
+//** Border color for table and cell borders.
+@table-border-color: #ddd;
+
+
+//== Buttons
+//
+//## For each of Bootstrap's buttons, define text, background and border color.
+
+@btn-font-weight: normal;
+
+@btn-default-color: #333;
+@btn-default-bg: #fff;
+@btn-default-border: #ccc;
+
+@btn-primary-color: #fff;
+@btn-primary-bg: @brand-primary;
+@btn-primary-border: darken(@btn-primary-bg, 5%);
+
+@btn-success-color: #fff;
+@btn-success-bg: @brand-success;
+@btn-success-border: darken(@btn-success-bg, 5%);
+
+@btn-info-color: #fff;
+@btn-info-bg: @brand-info;
+@btn-info-border: darken(@btn-info-bg, 5%);
+
+@btn-warning-color: #fff;
+@btn-warning-bg: @brand-warning;
+@btn-warning-border: darken(@btn-warning-bg, 5%);
+
+@btn-danger-color: #fff;
+@btn-danger-bg: @brand-danger;
+@btn-danger-border: darken(@btn-danger-bg, 5%);
+
+@btn-link-disabled-color: @gray-light;
+
+
+//== Forms
+//
+//##
+
+//** `` background color
+@input-bg: #fff;
+//** `` background color
+@input-bg-disabled: @gray-lighter;
+
+//** Text color for ``s
+@input-color: @gray;
+//** `` border color
+@input-border: #ccc;
+
+// TODO: Rename `@input-border-radius` to `@input-border-radius-base` in v4
+//** Default `.form-control` border radius
+@input-border-radius: @border-radius-base;
+//** Large `.form-control` border radius
+@input-border-radius-large: @border-radius-large;
+//** Small `.form-control` border radius
+@input-border-radius-small: @border-radius-small;
+
+//** Border color for inputs on focus
+@input-border-focus: #66afe9;
+
+//** Placeholder text color
+@input-color-placeholder: #999;
+
+//** Default `.form-control` height
+@input-height-base: (@line-height-computed + (@padding-base-vertical * 2) + 2);
+//** Large `.form-control` height
+@input-height-large: (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2);
+//** Small `.form-control` height
+@input-height-small: (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2);
+
+@legend-color: @gray-dark;
+@legend-border-color: #e5e5e5;
+
+//** Background color for textual input addons
+@input-group-addon-bg: @gray-lighter;
+//** Border color for textual input addons
+@input-group-addon-border-color: @input-border;
+
+//** Disabled cursor for form controls and buttons.
+@cursor-disabled: not-allowed;
+
+
+//== Dropdowns
+//
+//## Dropdown menu container and contents.
+
+//** Background for the dropdown menu.
+@dropdown-bg: #fff;
+//** Dropdown menu `border-color`.
+@dropdown-border: rgba(0,0,0,.15);
+//** Dropdown menu `border-color` **for IE8**.
+@dropdown-fallback-border: #ccc;
+//** Divider color for between dropdown items.
+@dropdown-divider-bg: #e5e5e5;
+
+//** Dropdown link text color.
+@dropdown-link-color: @gray-dark;
+//** Hover color for dropdown links.
+@dropdown-link-hover-color: darken(@gray-dark, 5%);
+//** Hover background for dropdown links.
+@dropdown-link-hover-bg: #f5f5f5;
+
+//** Active dropdown menu item text color.
+@dropdown-link-active-color: @component-active-color;
+//** Active dropdown menu item background color.
+@dropdown-link-active-bg: @component-active-bg;
+
+//** Disabled dropdown menu item background color.
+@dropdown-link-disabled-color: @gray-light;
+
+//** Text color for headers within dropdown menus.
+@dropdown-header-color: @gray-light;
+
+//** Deprecated `@dropdown-caret-color` as of v3.1.0
+@dropdown-caret-color: #000;
+
+
+//-- Z-index master list
+//
+// Warning: Avoid customizing these values. They're used for a bird's eye view
+// of components dependent on the z-axis and are designed to all work together.
+//
+// Note: These variables are not generated into the Customizer.
+
+@zindex-navbar: 1000;
+@zindex-dropdown: 1000;
+@zindex-popover: 1060;
+@zindex-tooltip: 1070;
+@zindex-navbar-fixed: 1030;
+@zindex-modal: 1040;
+
+
+//== Media queries breakpoints
+//
+//## Define the breakpoints at which your layout will change, adapting to different screen sizes.
+
+// Extra small screen / phone
+//** Deprecated `@screen-xs` as of v3.0.1
+@screen-xs: 480px;
+//** Deprecated `@screen-xs-min` as of v3.2.0
+@screen-xs-min: @screen-xs;
+//** Deprecated `@screen-phone` as of v3.0.1
+@screen-phone: @screen-xs-min;
+
+// Small screen / tablet
+//** Deprecated `@screen-sm` as of v3.0.1
+@screen-sm: 768px;
+@screen-sm-min: @screen-sm;
+//** Deprecated `@screen-tablet` as of v3.0.1
+@screen-tablet: @screen-sm-min;
+
+// Medium screen / desktop
+//** Deprecated `@screen-md` as of v3.0.1
+@screen-md: 992px;
+@screen-md-min: @screen-md;
+//** Deprecated `@screen-desktop` as of v3.0.1
+@screen-desktop: @screen-md-min;
+
+// Large screen / wide desktop
+//** Deprecated `@screen-lg` as of v3.0.1
+@screen-lg: 1200px;
+@screen-lg-min: @screen-lg;
+//** Deprecated `@screen-lg-desktop` as of v3.0.1
+@screen-lg-desktop: @screen-lg-min;
+
+// So media queries don't overlap when required, provide a maximum
+@screen-xs-max: (@screen-sm-min - 1);
+@screen-sm-max: (@screen-md-min - 1);
+@screen-md-max: (@screen-lg-min - 1);
+
+
+//== Grid system
+//
+//## Define your custom responsive grid.
+
+//** Number of columns in the grid.
+@grid-columns: 12;
+//** Padding between columns. Gets divided in half for the left and right.
+@grid-gutter-width: 30px;
+// Navbar collapse
+//** Point at which the navbar becomes uncollapsed.
+@grid-float-breakpoint: @screen-sm-min;
+//** Point at which the navbar begins collapsing.
+@grid-float-breakpoint-max: (@grid-float-breakpoint - 1);
+
+
+//== Container sizes
+//
+//## Define the maximum width of `.container` for different screen sizes.
+
+// Small screen / tablet
+@container-tablet: (720px + @grid-gutter-width);
+//** For `@screen-sm-min` and up.
+@container-sm: @container-tablet;
+
+// Medium screen / desktop
+@container-desktop: (940px + @grid-gutter-width);
+//** For `@screen-md-min` and up.
+@container-md: @container-desktop;
+
+// Large screen / wide desktop
+@container-large-desktop: (1140px + @grid-gutter-width);
+//** For `@screen-lg-min` and up.
+@container-lg: @container-large-desktop;
+
+
+//== Navbar
+//
+//##
+
+// Basics of a navbar
+@navbar-height: 50px;
+@navbar-margin-bottom: @line-height-computed;
+@navbar-border-radius: @border-radius-base;
+@navbar-padding-horizontal: floor((@grid-gutter-width / 2));
+@navbar-padding-vertical: ((@navbar-height - @line-height-computed) / 2);
+@navbar-collapse-max-height: 340px;
+
+@navbar-default-color: #777;
+@navbar-default-bg: #f8f8f8;
+@navbar-default-border: darken(@navbar-default-bg, 6.5%);
+
+// Navbar links
+@navbar-default-link-color: #777;
+@navbar-default-link-hover-color: #333;
+@navbar-default-link-hover-bg: transparent;
+@navbar-default-link-active-color: #555;
+@navbar-default-link-active-bg: darken(@navbar-default-bg, 6.5%);
+@navbar-default-link-disabled-color: #ccc;
+@navbar-default-link-disabled-bg: transparent;
+
+// Navbar brand label
+@navbar-default-brand-color: @navbar-default-link-color;
+@navbar-default-brand-hover-color: darken(@navbar-default-brand-color, 10%);
+@navbar-default-brand-hover-bg: transparent;
+
+// Navbar toggle
+@navbar-default-toggle-hover-bg: #ddd;
+@navbar-default-toggle-icon-bar-bg: #888;
+@navbar-default-toggle-border-color: #ddd;
+
+
+// Inverted navbar
+// Reset inverted navbar basics
+@navbar-inverse-color: lighten(@gray-light, 15%);
+@navbar-inverse-bg: #222;
+@navbar-inverse-border: darken(@navbar-inverse-bg, 10%);
+
+// Inverted navbar links
+@navbar-inverse-link-color: lighten(@gray-light, 15%);
+@navbar-inverse-link-hover-color: #fff;
+@navbar-inverse-link-hover-bg: transparent;
+@navbar-inverse-link-active-color: @navbar-inverse-link-hover-color;
+@navbar-inverse-link-active-bg: darken(@navbar-inverse-bg, 10%);
+@navbar-inverse-link-disabled-color: #444;
+@navbar-inverse-link-disabled-bg: transparent;
+
+// Inverted navbar brand label
+@navbar-inverse-brand-color: @navbar-inverse-link-color;
+@navbar-inverse-brand-hover-color: #fff;
+@navbar-inverse-brand-hover-bg: transparent;
+
+// Inverted navbar toggle
+@navbar-inverse-toggle-hover-bg: #333;
+@navbar-inverse-toggle-icon-bar-bg: #fff;
+@navbar-inverse-toggle-border-color: #333;
+
+
+//== Navs
+//
+//##
+
+//=== Shared nav styles
+@nav-link-padding: 10px 15px;
+@nav-link-hover-bg: @gray-lighter;
+
+@nav-disabled-link-color: @gray-light;
+@nav-disabled-link-hover-color: @gray-light;
+
+//== Tabs
+@nav-tabs-border-color: #ddd;
+
+@nav-tabs-link-hover-border-color: @gray-lighter;
+
+@nav-tabs-active-link-hover-bg: @body-bg;
+@nav-tabs-active-link-hover-color: @gray;
+@nav-tabs-active-link-hover-border-color: #ddd;
+
+@nav-tabs-justified-link-border-color: #ddd;
+@nav-tabs-justified-active-link-border-color: @body-bg;
+
+//== Pills
+@nav-pills-border-radius: @border-radius-base;
+@nav-pills-active-link-hover-bg: @component-active-bg;
+@nav-pills-active-link-hover-color: @component-active-color;
+
+
+//== Pagination
+//
+//##
+
+@pagination-color: @link-color;
+@pagination-bg: #fff;
+@pagination-border: #ddd;
+
+@pagination-hover-color: @link-hover-color;
+@pagination-hover-bg: @gray-lighter;
+@pagination-hover-border: #ddd;
+
+@pagination-active-color: #fff;
+@pagination-active-bg: @brand-primary;
+@pagination-active-border: @brand-primary;
+
+@pagination-disabled-color: @gray-light;
+@pagination-disabled-bg: #fff;
+@pagination-disabled-border: #ddd;
+
+
+//== Pager
+//
+//##
+
+@pager-bg: @pagination-bg;
+@pager-border: @pagination-border;
+@pager-border-radius: 15px;
+
+@pager-hover-bg: @pagination-hover-bg;
+
+@pager-active-bg: @pagination-active-bg;
+@pager-active-color: @pagination-active-color;
+
+@pager-disabled-color: @pagination-disabled-color;
+
+
+//== Jumbotron
+//
+//##
+
+@jumbotron-padding: 30px;
+@jumbotron-color: inherit;
+@jumbotron-bg: @gray-lighter;
+@jumbotron-heading-color: inherit;
+@jumbotron-font-size: ceil((@font-size-base * 1.5));
+
+
+//== Form states and alerts
+//
+//## Define colors for form feedback states and, by default, alerts.
+
+@state-success-text: #3c763d;
+@state-success-bg: #dff0d8;
+@state-success-border: darken(spin(@state-success-bg, -10), 5%);
+
+@state-info-text: #31708f;
+@state-info-bg: #d9edf7;
+@state-info-border: darken(spin(@state-info-bg, -10), 7%);
+
+@state-warning-text: #8a6d3b;
+@state-warning-bg: #fcf8e3;
+@state-warning-border: darken(spin(@state-warning-bg, -10), 5%);
+
+@state-danger-text: #a94442;
+@state-danger-bg: #f2dede;
+@state-danger-border: darken(spin(@state-danger-bg, -10), 5%);
+
+
+//== Tooltips
+//
+//##
+
+//** Tooltip max width
+@tooltip-max-width: 200px;
+//** Tooltip text color
+@tooltip-color: #fff;
+//** Tooltip background color
+@tooltip-bg: #000;
+@tooltip-opacity: .9;
+
+//** Tooltip arrow width
+@tooltip-arrow-width: 5px;
+//** Tooltip arrow color
+@tooltip-arrow-color: @tooltip-bg;
+
+
+//== Popovers
+//
+//##
+
+//** Popover body background color
+@popover-bg: #fff;
+//** Popover maximum width
+@popover-max-width: 276px;
+//** Popover border color
+@popover-border-color: rgba(0,0,0,.2);
+//** Popover fallback border color
+@popover-fallback-border-color: #ccc;
+
+//** Popover title background color
+@popover-title-bg: darken(@popover-bg, 3%);
+
+//** Popover arrow width
+@popover-arrow-width: 10px;
+//** Popover arrow color
+@popover-arrow-color: @popover-bg;
+
+//** Popover outer arrow width
+@popover-arrow-outer-width: (@popover-arrow-width + 1);
+//** Popover outer arrow color
+@popover-arrow-outer-color: fadein(@popover-border-color, 5%);
+//** Popover outer arrow fallback color
+@popover-arrow-outer-fallback-color: darken(@popover-fallback-border-color, 20%);
+
+
+//== Labels
+//
+//##
+
+//** Default label background color
+@label-default-bg: @gray-light;
+//** Primary label background color
+@label-primary-bg: @brand-primary;
+//** Success label background color
+@label-success-bg: @brand-success;
+//** Info label background color
+@label-info-bg: @brand-info;
+//** Warning label background color
+@label-warning-bg: @brand-warning;
+//** Danger label background color
+@label-danger-bg: @brand-danger;
+
+//** Default label text color
+@label-color: #fff;
+//** Default text color of a linked label
+@label-link-hover-color: #fff;
+
+
+//== Modals
+//
+//##
+
+//** Padding applied to the modal body
+@modal-inner-padding: 15px;
+
+//** Padding applied to the modal title
+@modal-title-padding: 15px;
+//** Modal title line-height
+@modal-title-line-height: @line-height-base;
+
+//** Background color of modal content area
+@modal-content-bg: #fff;
+//** Modal content border color
+@modal-content-border-color: rgba(0,0,0,.2);
+//** Modal content border color **for IE8**
+@modal-content-fallback-border-color: #999;
+
+//** Modal backdrop background color
+@modal-backdrop-bg: #000;
+//** Modal backdrop opacity
+@modal-backdrop-opacity: .5;
+//** Modal header border color
+@modal-header-border-color: #e5e5e5;
+//** Modal footer border color
+@modal-footer-border-color: @modal-header-border-color;
+
+@modal-lg: 900px;
+@modal-md: 600px;
+@modal-sm: 300px;
+
+
+//== Alerts
+//
+//## Define alert colors, border radius, and padding.
+
+@alert-padding: 15px;
+@alert-border-radius: @border-radius-base;
+@alert-link-font-weight: bold;
+
+@alert-success-bg: @state-success-bg;
+@alert-success-text: @state-success-text;
+@alert-success-border: @state-success-border;
+
+@alert-info-bg: @state-info-bg;
+@alert-info-text: @state-info-text;
+@alert-info-border: @state-info-border;
+
+@alert-warning-bg: @state-warning-bg;
+@alert-warning-text: @state-warning-text;
+@alert-warning-border: @state-warning-border;
+
+@alert-danger-bg: @state-danger-bg;
+@alert-danger-text: @state-danger-text;
+@alert-danger-border: @state-danger-border;
+
+
+//== Progress bars
+//
+//##
+
+//** Background color of the whole progress component
+@progress-bg: #f5f5f5;
+//** Progress bar text color
+@progress-bar-color: #fff;
+//** Variable for setting rounded corners on progress bar.
+@progress-border-radius: @border-radius-base;
+
+//** Default progress bar color
+@progress-bar-bg: @brand-primary;
+//** Success progress bar color
+@progress-bar-success-bg: @brand-success;
+//** Warning progress bar color
+@progress-bar-warning-bg: @brand-warning;
+//** Danger progress bar color
+@progress-bar-danger-bg: @brand-danger;
+//** Info progress bar color
+@progress-bar-info-bg: @brand-info;
+
+
+//== List group
+//
+//##
+
+//** Background color on `.list-group-item`
+@list-group-bg: #fff;
+//** `.list-group-item` border color
+@list-group-border: #ddd;
+//** List group border radius
+@list-group-border-radius: @border-radius-base;
+
+//** Background color of single list items on hover
+@list-group-hover-bg: #f5f5f5;
+//** Text color of active list items
+@list-group-active-color: @component-active-color;
+//** Background color of active list items
+@list-group-active-bg: @component-active-bg;
+//** Border color of active list elements
+@list-group-active-border: @list-group-active-bg;
+//** Text color for content within active list items
+@list-group-active-text-color: lighten(@list-group-active-bg, 40%);
+
+//** Text color of disabled list items
+@list-group-disabled-color: @gray-light;
+//** Background color of disabled list items
+@list-group-disabled-bg: @gray-lighter;
+//** Text color for content within disabled list items
+@list-group-disabled-text-color: @list-group-disabled-color;
+
+@list-group-link-color: #555;
+@list-group-link-hover-color: @list-group-link-color;
+@list-group-link-heading-color: #333;
+
+
+//== Panels
+//
+//##
+
+@panel-bg: #fff;
+@panel-body-padding: 15px;
+@panel-heading-padding: 10px 15px;
+@panel-footer-padding: @panel-heading-padding;
+@panel-border-radius: @border-radius-base;
+
+//** Border color for elements within panels
+@panel-inner-border: #ddd;
+@panel-footer-bg: #f5f5f5;
+
+@panel-default-text: @gray-dark;
+@panel-default-border: #ddd;
+@panel-default-heading-bg: #f5f5f5;
+
+@panel-primary-text: #fff;
+@panel-primary-border: @brand-primary;
+@panel-primary-heading-bg: @brand-primary;
+
+@panel-success-text: @state-success-text;
+@panel-success-border: @state-success-border;
+@panel-success-heading-bg: @state-success-bg;
+
+@panel-info-text: @state-info-text;
+@panel-info-border: @state-info-border;
+@panel-info-heading-bg: @state-info-bg;
+
+@panel-warning-text: @state-warning-text;
+@panel-warning-border: @state-warning-border;
+@panel-warning-heading-bg: @state-warning-bg;
+
+@panel-danger-text: @state-danger-text;
+@panel-danger-border: @state-danger-border;
+@panel-danger-heading-bg: @state-danger-bg;
+
+
+//== Thumbnails
+//
+//##
+
+//** Padding around the thumbnail image
+@thumbnail-padding: 4px;
+//** Thumbnail background color
+@thumbnail-bg: @body-bg;
+//** Thumbnail border color
+@thumbnail-border: #ddd;
+//** Thumbnail border radius
+@thumbnail-border-radius: @border-radius-base;
+
+//** Custom text color for thumbnail captions
+@thumbnail-caption-color: @text-color;
+//** Padding around the thumbnail caption
+@thumbnail-caption-padding: 9px;
+
+
+//== Wells
+//
+//##
+
+@well-bg: #f5f5f5;
+@well-border: darken(@well-bg, 7%);
+
+
+//== Badges
+//
+//##
+
+@badge-color: #fff;
+//** Linked badge text color on hover
+@badge-link-hover-color: #fff;
+@badge-bg: @gray-light;
+
+//** Badge text color in active nav link
+@badge-active-color: @link-color;
+//** Badge background color in active nav link
+@badge-active-bg: #fff;
+
+@badge-font-weight: bold;
+@badge-line-height: 1;
+@badge-border-radius: 10px;
+
+
+//== Breadcrumbs
+//
+//##
+
+@breadcrumb-padding-vertical: 8px;
+@breadcrumb-padding-horizontal: 15px;
+//** Breadcrumb background color
+@breadcrumb-bg: #f5f5f5;
+//** Breadcrumb text color
+@breadcrumb-color: #ccc;
+//** Text color of current page in the breadcrumb
+@breadcrumb-active-color: @gray-light;
+//** Textual separator for between breadcrumb elements
+@breadcrumb-separator: "/";
+
+
+//== Carousel
+//
+//##
+
+@carousel-text-shadow: 0 1px 2px rgba(0,0,0,.6);
+
+@carousel-control-color: #fff;
+@carousel-control-width: 15%;
+@carousel-control-opacity: .5;
+@carousel-control-font-size: 20px;
+
+@carousel-indicator-active-bg: #fff;
+@carousel-indicator-border-color: #fff;
+
+@carousel-caption-color: #fff;
+
+
+//== Close
+//
+//##
+
+@close-font-weight: bold;
+@close-color: #000;
+@close-text-shadow: 0 1px 0 #fff;
+
+
+//== Code
+//
+//##
+
+@code-color: #c7254e;
+@code-bg: #f9f2f4;
+
+@kbd-color: #fff;
+@kbd-bg: #333;
+
+@pre-bg: #f5f5f5;
+@pre-color: @gray-dark;
+@pre-border-color: #ccc;
+@pre-scrollable-max-height: 340px;
+
+
+//== Type
+//
+//##
+
+//** Horizontal offset for forms and lists.
+@component-offset-horizontal: 180px;
+//** Text muted color
+@text-muted: @gray-light;
+//** Abbreviations and acronyms border color
+@abbr-border-color: @gray-light;
+//** Headings small color
+@headings-small-color: @gray-light;
+//** Blockquote small color
+@blockquote-small-color: @gray-light;
+//** Blockquote font size
+@blockquote-font-size: (@font-size-base * 1.25);
+//** Blockquote border color
+@blockquote-border-color: @gray-lighter;
+//** Page header border color
+@page-header-border-color: @gray-lighter;
+//** Width of horizontal description list titles
+@dl-horizontal-offset: @component-offset-horizontal;
+//** Horizontal line color.
+@hr-border: @gray-lighter;
diff --git a/ui/less/bootstrap/wells.less b/ui/less/bootstrap/wells.less
new file mode 100644
index 0000000..15d072b
--- /dev/null
+++ b/ui/less/bootstrap/wells.less
@@ -0,0 +1,29 @@
+//
+// Wells
+// --------------------------------------------------
+
+
+// Base class
+.well {
+ min-height: 20px;
+ padding: 19px;
+ margin-bottom: 20px;
+ background-color: @well-bg;
+ border: 1px solid @well-border;
+ border-radius: @border-radius-base;
+ .box-shadow(inset 0 1px 1px rgba(0,0,0,.05));
+ blockquote {
+ border-color: #ddd;
+ border-color: rgba(0,0,0,.15);
+ }
+}
+
+// Sizes
+.well-lg {
+ padding: 24px;
+ border-radius: @border-radius-large;
+}
+.well-sm {
+ padding: 9px;
+ border-radius: @border-radius-small;
+}
diff --git a/ui/less/com/address-book.less b/ui/less/com/address-book.less
new file mode 100644
index 0000000..8a56ee1
--- /dev/null
+++ b/ui/less/com/address-book.less
@@ -0,0 +1,35 @@
+.address-book-controls {
+ display: flex;
+ max-width: 1000px;
+ background: #fff;
+ margin: 24px auto 0;
+ border-radius: 5px;
+ border: 1px solid #ccc;
+
+ h4 {
+ color: #555;
+ white-space: pre;
+ }
+ p {
+ color: gray;
+ }
+
+ .search {
+ padding: 0 24px;
+ input {
+ height: 30px;
+ margin-top: 14px;
+ }
+ }
+
+ .getcode {
+ flex: 0 0 260px;
+ cursor: pointer;
+ padding: 0 12px 0 24px;
+ border-left: 1px solid #ccc;
+
+ &:hover {
+ background: #f5f5f5;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/advert-form.less b/ui/less/com/advert-form.less
new file mode 100644
index 0000000..72de0be
--- /dev/null
+++ b/ui/less/com/advert-form.less
@@ -0,0 +1,33 @@
+.advert-form {
+ margin-top: 1em;
+
+ .open {
+ display: none;
+ }
+ &.opened {
+ .open {
+ display: block;
+ }
+ .closed {
+ display: none;
+ }
+ }
+ .preview {
+ margin-top: 1em;
+ color: #555;
+ }
+ .preview:empty {
+ display: none;
+ }
+ .preview > :last-child {
+ margin-bottom: 0;
+ }
+ .post-form-btns {
+ button, a {
+ margin-right: 5px;
+ }
+ a {
+ color: @link-color;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/adverts.less b/ui/less/com/adverts.less
new file mode 100644
index 0000000..bc43487
--- /dev/null
+++ b/ui/less/com/adverts.less
@@ -0,0 +1,11 @@
+.adverts-but-its-cool-tho {
+ max-width: 200px;
+ .well {
+ color: #808080;
+ margin-bottom: 1em;
+ padding: 9px;
+ & > :last-child {
+ margin-bottom: 0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/composer.less b/ui/less/com/composer.less
new file mode 100644
index 0000000..9bc7036
--- /dev/null
+++ b/ui/less/com/composer.less
@@ -0,0 +1,53 @@
+
+.composer {
+ margin: 15px 24px;
+ &.reply {
+ margin-top: 0;
+ }
+
+ .composer-body {
+ padding: 10px;
+ border: 1px solid #ccc;
+ background: #fff;
+ }
+}
+
+.composer-header {
+ position: relative;
+ margin: 5px 0 0 88px;
+
+ .composer-header-nav {
+ position: absolute;
+ top: 0;
+ left: -40px;
+ width: 38px;
+
+ a {
+ display: none;
+ position: relative;
+ padding: 6px 12px;
+ color: gray;
+ border-radius: 2px;
+ cursor: pointer;
+ text-shadow: 0 2px 3px rgba(0,0,0,0.15);
+
+ &:hover {
+ color: #555;
+ }
+ &.selected {
+ display: inline-block;
+ }
+ }
+ &:hover {
+ background: #fff;
+ z-index: 1000;
+ box-shadow: 2px 3px 3px rgba(0, 0, 0, 0.15);
+ a {
+ display: inline-block;
+ }
+ }
+ }
+
+ .composer-header-body {
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/contact-feed.less b/ui/less/com/contact-feed.less
new file mode 100644
index 0000000..0659ece
--- /dev/null
+++ b/ui/less/com/contact-feed.less
@@ -0,0 +1,16 @@
+.contact-feed-container {
+
+ .contact-feed {
+ max-width: 960px;
+ margin: 0 auto;
+
+ &:empty::before {
+ content: 'Empty';
+ font-size: 16px;
+ font-style: italic;
+ color: #777;
+ margin: 1em;
+ display: block;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/contact-listing.less b/ui/less/com/contact-listing.less
new file mode 100644
index 0000000..48ccf3a
--- /dev/null
+++ b/ui/less/com/contact-listing.less
@@ -0,0 +1,64 @@
+.contact-listing {
+ display: flex;
+ color: #555;
+
+ .profpic {
+ margin-left: 10px;
+ height: 75px;
+ }
+ .details {
+ flex: 1;
+ margin-left: 14px;
+ padding: 10px 0;
+ p {
+ margin: 0 15px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .name {
+ font-size: 21px;
+
+ a {
+ color: #444;
+ font-weight: bold;
+ }
+ }
+ }
+ .actions {
+ padding: 24px 10px 0 0;
+ }
+ .text-danger {
+ color: #FD241F;
+ }
+
+ &:not(.compact):nth-child(odd) {
+ background: #fff;
+ .details {
+ margin-left: 60px;
+ }
+ }
+ &:not(.compact):nth-child(even) {
+ .profpic {
+ margin-left: 54px;
+ }
+ }
+ &.compact {
+ .details {
+ margin-left: 8px;
+ }
+ .profpic {
+ height: auto;
+ margin-top: 10px;
+ }
+ }
+}
+
+.peers {
+ margin: 0;
+}
+
+.address-book-page {
+ .message-feed-container {
+ padding-top: 10px;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/contact-summary.less b/ui/less/com/contact-summary.less
new file mode 100644
index 0000000..b466d68
--- /dev/null
+++ b/ui/less/com/contact-summary.less
@@ -0,0 +1,133 @@
+.contact-summary {
+ margin: 5px 10px;
+ display: inline-block;
+ vertical-align: top;
+
+ h2, p {
+ margin: 0;
+ }
+ .section {
+ margin-bottom: 24px;
+ }
+ li {
+ display: inline-block;
+ margin-right: 1em;
+ }
+ .title {
+ width: 275px;
+ text-align: center;
+ margin: 15px 0 10px;
+
+ h2 {
+ font-weight: bold;
+ }
+ h3 {
+ margin: 1px 0 0;
+ font-size: 21px;
+ .user-link {
+ color: inherit;
+ }
+ }
+ }
+
+ .profpic {
+ position: relative;
+ display: inline-block;
+ .hexagon-275 {
+ z-index: 10;
+ }
+ .hexTop, .hexBottom {
+ outline: 5px solid #eee;
+ }
+ }
+
+ .totem {
+ position: relative;
+ width: 275px;
+ height: 318px;
+
+ .corner {
+ position: absolute;
+ z-index: 5;
+ display: block;
+ width: 130px;
+ height: 100px;
+ background: #282C32;
+ color: #2778E2;
+ border-radius: 2px;
+ font-family: @font-family-monospace;
+ font-size: 21px;
+ .corner-inner {
+ position: absolute;
+ }
+ &:hover:after {
+ content: attr(data-overlay);
+ position: absolute;
+ background: inherit;
+ border-radius: 2px;
+ width: 100%;
+ font-family: @font-family-sans-serif;
+ font-size: 15px;
+ }
+ }
+ .topleft {
+ top: 10px;
+ left: 10px;
+ .corner-inner {
+ left: 9px;
+ top: 4px;
+ }
+ &:hover:after {
+ top: 0;
+ left: 0;
+ padding: 4px 6px 50px;
+ }
+ }
+ .topright {
+ top: 10px;
+ right: 10px;
+ .corner-inner {
+ right: 11px;
+ top: 4px;
+ }
+ }
+ .botleft {
+ bottom: 10px;
+ left: 10px;
+ .corner-inner {
+ left: 9px;
+ top: 68px;
+ }
+ &:hover:after {
+ bottom: 0;
+ left: 0;
+ padding: 50px 6px 4px;
+ }
+ }
+ .botright {
+ bottom: 10px;
+ right: 10px;
+ .corner-inner {
+ right: 11px;
+ top: 68px;
+ }
+ &:hover:after {
+ bottom: 0;
+ right: 0;
+ padding: 50px 6px 4px;
+ text-align: right;
+ }
+ }
+ .glyphicon {
+ font-size: 12px;
+ margin: 0 3px;
+ }
+ }
+
+ .relations {
+ margin: 0 0 0 10px;
+ .user-hexagon {
+ opacity: 1;
+ }
+ }
+}
diff --git a/ui/less/com/contact-sync-listing.less b/ui/less/com/contact-sync-listing.less
new file mode 100644
index 0000000..427aad8
--- /dev/null
+++ b/ui/less/com/contact-sync-listing.less
@@ -0,0 +1,39 @@
+.contact-sync-listing {
+
+ &.empty {
+ padding: 10px;
+ background: #fff;
+ font-size: 18px;
+ }
+
+ .peer {
+ margin: 10px 0;
+ }
+ .peer-title {
+ font-size: 21px;
+ padding: 5px 10px;
+ background: #fff;
+ position: relative;
+
+ &:before {
+ content: attr(data-history);
+ position: absolute;
+ bottom: 10px;
+ left: 77px;
+ font-size: 12px;
+ color: gray;
+ }
+
+ .hexagon-60 {
+ display: inline-block;
+ vertical-align: middle;
+ }
+ }
+ .contact-feed-container {
+ padding: 10px;
+ &:before {
+ content: 'follows:';
+ color: #777;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/dropdown.less b/ui/less/com/dropdown.less
new file mode 100644
index 0000000..2257ff6
--- /dev/null
+++ b/ui/less/com/dropdown.less
@@ -0,0 +1,51 @@
+.dropdown {
+ position: absolute;
+ z-index: 10000;
+ width: 200px;
+ padding: 6px 0;
+ background: #fff;
+ box-shadow: 0px 3px 3px rgba(0,0,0,0.1);
+ border: 1px solid #bbb;
+
+ &:before {
+ content: '';
+ position: absolute;
+ left: 17px;
+ width: 10px;
+ height: 10px;
+ top: -10px;
+ border-width: 0 7px 8px;
+ border-bottom-color: #ccc;
+ border-style: solid;
+ color: transparent;
+ }
+ &.nopad {
+ padding: 0;
+ }
+
+ .item {
+ display: block;
+ margin: 0;
+ padding: 6px 20px;
+ color: gray;
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: none;
+ color: #fff;
+ background: @brand-primary;
+ font-weight:100;
+ }
+ }
+ hr {
+ margin: 6px 0;
+ border-color: #ccc;
+ }
+
+ &.right {
+ &:before {
+ left: auto;
+ right: 17px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/error-form.less b/ui/less/com/error-form.less
new file mode 100644
index 0000000..2aeeb87
--- /dev/null
+++ b/ui/less/com/error-form.less
@@ -0,0 +1,26 @@
+.error-form {
+ .modal-form;
+ padding: 0;
+
+ .error-form-title {
+ padding: 6px 11px;
+ color: #666;
+ font-size: 21px;
+ background: url(../img/lines.svg);
+ border-bottom: 1px solid #ddd;
+ }
+ .error-form-message {
+ padding: 10px 12px;
+ font-size: 16px;
+ }
+ .error-form-actions {
+ padding: 12px;
+ .btn {
+ margin-right: 5px;
+ }
+ }
+ .error-form-stack {
+ border-radius: 0;
+ color: #777;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/ext.less b/ui/less/com/ext.less
new file mode 100644
index 0000000..fd4cbe2
--- /dev/null
+++ b/ui/less/com/ext.less
@@ -0,0 +1,27 @@
+.ext {
+ padding-bottom: 100px;
+ small {
+ background: #f5f5f5;
+ margin: 0;
+ }
+ .ext-txt {
+ white-space: pre-wrap;
+ font-family: @font-family-monospace;
+ }
+ .ext-markdown {
+ font-family: @font-family-sans-serif;
+ }
+ .ext-img {
+ max-width: 100%;
+ padding: 0;
+ border-top: 1px solid #999;
+ }
+ .ext-obj {
+ width: 100%;
+ min-height: 600px;
+ border: 1px solid #999;
+ }
+ .ext-html {
+ min-height: 600px;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/fact-form.less b/ui/less/com/fact-form.less
new file mode 100644
index 0000000..42c1847
--- /dev/null
+++ b/ui/less/com/fact-form.less
@@ -0,0 +1,16 @@
+.fact-form {
+ display: table;
+ .fact-form-subject {
+ display: table-cell;
+ white-space: pre;
+ padding: 0 5px;
+ }
+ .fact-form-textarea {
+ display: table-cell;
+ width: 100%;
+ padding: 0 5px;
+ }
+ button {
+ display: table-cell;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/files-view.less b/ui/less/com/files-view.less
new file mode 100644
index 0000000..705aee2
--- /dev/null
+++ b/ui/less/com/files-view.less
@@ -0,0 +1,93 @@
+.files-view {
+ .table;
+ .table-hover;
+ background: #fff;
+ white-space: pre;
+
+ td:nth-child(1) {
+ width: 80px;
+ border-right: 1px solid #ddd;
+ border-left: 1px solid #ddd;
+ }
+ td:nth-child(2) {
+ width: 28px;
+ }
+ td:nth-child(4) {
+ width: 180px;
+ }
+ td:nth-child(5) {
+ width: 80px;
+ border-right: 1px solid #ddd;
+ }
+ .folder {
+ cursor: pointer;
+ }
+
+ .glyphicon {
+ margin-right: 10px;
+ }
+}
+
+.files-view-pathctrl {
+ border: 1px solid #ccc;
+ padding: 7px 10px 6px 10px;
+ background: #fafafa;
+ position: relative;
+ left: -4px;
+ top: 2px;
+ border-left: 0;
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+ box-shadow: inset 2px 0 3px rgba(0,0,0,0.15);
+ color: #666;
+}
+
+.files-view-changes {
+ margin: 0 0 12px;
+
+ & > div {
+ display: flex;
+
+ & > div {
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ border-top: 1px solid;
+ border-right: 1px solid;
+ padding: 5px;
+ &:first-child {
+ border-left: 1px solid;
+ }
+ }
+ &:last-child > div {
+ border-bottom: 1px solid;
+ }
+ }
+ .action {
+ flex: 0 0 30px;
+ font-weight: bold;
+ text-align: center;
+ }
+ .path {
+ flex: 1;
+ }
+ .size {
+ flex: 0 0 80px;
+ }
+ .type {
+ flex: 0 0 180px;
+ }
+
+ .add {
+ background: #efe;
+ color: green;
+ }
+ .mod {
+ background: #eef;
+ color: blue;
+ }
+ .del {
+ background: #fee;
+ color: red;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/finder.less b/ui/less/com/finder.less
new file mode 100644
index 0000000..47587ae
--- /dev/null
+++ b/ui/less/com/finder.less
@@ -0,0 +1,16 @@
+#finder {
+ position: fixed;
+ top: 43px;
+ left: 5px;
+ width: 300px;
+ height: 32px;
+ z-index: 1000;
+ background-color: #fff;
+ border: 1px solid #ccc;
+ padding: 2px;
+
+ input {
+ display: block;
+ width: 100%;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/flag-form.less b/ui/less/com/flag-form.less
new file mode 100644
index 0000000..e984229
--- /dev/null
+++ b/ui/less/com/flag-form.less
@@ -0,0 +1,11 @@
+.flag-form {
+ .modal-form;
+ .radios {
+ margin-bottom: 10px;
+ background: #fafafa;
+ }
+ .radio {
+ display: inline-block;
+ margin: 10px;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/header-controls.less b/ui/less/com/header-controls.less
new file mode 100644
index 0000000..2bd4ea3
--- /dev/null
+++ b/ui/less/com/header-controls.less
@@ -0,0 +1,97 @@
+.header-ctrls {
+ font-size: 16px;
+ background: #fff;
+ margin: 10px 5px;
+ border: 1px solid #ccc;
+ border-radius: 2px;
+ padding: 5px;
+
+ .navlinks {
+ display: table-cell;
+ white-space: pre;
+ vertical-align: middle;
+
+ a {
+ display: inline-block;
+ padding: 4px 15px;
+ margin-right: 5px;
+ font-weight: 100;
+ color: #769142;
+
+ &:hover {
+ text-decoration: none;
+ }
+ &.selected {
+ color: #5E6F3C;
+ }
+ &.highlight {
+ background: #71A800;
+ color: #FFF;
+ border-radius: 2px;
+ }
+ }
+ }
+ .btns {
+ display: table-cell;
+ vertical-align: top;
+ a {
+ display: block;
+ width: 50px;
+ text-align: center;
+ font-size: 21px;
+ color: gray;
+ position: relative;
+ top: 15px;
+ &:hover {
+ color: #666;
+ }
+ }
+ }
+ & > form {
+ display: table-cell;
+ width: 100%;
+ input {
+ border: 1px solid #ccc;
+ padding: 5px 10px;
+ &.search {
+ padding: 5px 12px;
+ border-radius: 15px;
+ }
+ }
+ }
+
+ &.big {
+ font-size: 18px;
+
+ .navlinks a {
+ padding: 15px;
+ }
+ }
+
+ &.light {
+
+ .navlinks a {
+ color: #777;
+ background: #fff;
+ border: 0;
+ border-top: 1px solid #ccc;
+ border-bottom: 3px solid #ccc;
+ margin-right: 0;
+
+ &:first-child {
+ border-left: 1px solid #ccc;
+ }
+ &:last-child {
+ border-right: 1px solid #ccc;
+ }
+
+ &:hover {
+ color: #555;
+ }
+ &.selected {
+ color: #333;
+ border-bottom-color: #777;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/helptip.less b/ui/less/com/helptip.less
new file mode 100644
index 0000000..d4eca39
--- /dev/null
+++ b/ui/less/com/helptip.less
@@ -0,0 +1,16 @@
+.helptip {
+ display: none;
+ position: fixed;
+ top: 1%;
+ left: 1%;
+ width: 340px;
+ background: white;
+ padding: 20px;
+ border: 1px solid #ccc;
+ z-index: 1000;
+ box-shadow: 0px 3px 5px rgba(0,0,0,0.15);
+
+ &.active {
+ display: block;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/hexagon.less b/ui/less/com/hexagon.less
new file mode 100644
index 0000000..9979f29
--- /dev/null
+++ b/ui/less/com/hexagon.less
@@ -0,0 +1,513 @@
+// courtesy of csshexagon.com
+
+.hexagon-30 {
+ position: relative;
+ width: 30px;
+ height: 17.32px;
+ margin: 8.66px 0;
+ background-size: auto 34.6410px;
+ background-position: center;
+
+ .hexTop,
+ .hexBottom {
+ position: absolute;
+ z-index: 1;
+ width: 21.21px;
+ height: 21.21px;
+ overflow: hidden;
+ -webkit-transform: scaleY(0.5774) rotate(-45deg);
+ -ms-transform: scaleY(0.5774) rotate(-45deg);
+ transform: scaleY(0.5774) rotate(-45deg);
+ background: inherit;
+ left: 4.39px;
+
+ /* Keeps borders smooth in webkit */
+ backface-visibility: hidden;
+ }
+
+ /*counter transform the bg image on the caps*/
+ .hexTop:after,
+ .hexBottom:after {
+ content: "";
+ position: absolute;
+ width: 30.0000px;
+ height: 17.320508075688775px;
+ -webkit-transform: rotate(45deg) scaleY(1.7321) translateY(-8.6603px);
+ -ms-transform: rotate(45deg) scaleY(1.7321) translateY(-8.6603px);
+ transform: rotate(45deg) scaleY(1.7321) translateY(-8.6603px);
+ -webkit-transform-origin: 0 0;
+ -ms-transform-origin: 0 0;
+ transform-origin: 0 0;
+ background: inherit;
+ }
+
+ .hexTop {
+ top: -10.6066px;
+ }
+
+ .hexTop:after {
+ background-position: center top;
+ }
+
+ .hexBottom {
+ bottom: -10.6066px;
+ }
+
+ .hexBottom:after {
+ background-position: center bottom;
+ }
+
+ &:after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 30.0000px;
+ height: 17.3205px;
+ z-index: 2;
+ background: inherit;
+ }
+}
+
+.hexagon-45 {
+ position: relative;
+ width: 45px;
+ height: 25.98px;
+ margin: 12.99px 0;
+ background-size: auto 51.9615px;
+ background-position: center;
+
+ .hexTop,
+ .hexBottom {
+ position: absolute;
+ z-index: 1;
+ width: 31.82px;
+ height: 31.82px;
+ overflow: hidden;
+ -webkit-transform: scaleY(0.5774) rotate(-45deg);
+ -ms-transform: scaleY(0.5774) rotate(-45deg);
+ transform: scaleY(0.5774) rotate(-45deg);
+ background: inherit;
+ left: 6.59px;
+
+ /* Keeps borders smooth in webkit */
+ backface-visibility: hidden;
+ }
+
+ /*counter transform the bg image on the caps*/
+ .hexTop:after,
+ .hexBottom:after {
+ content: "";
+ position: absolute;
+ width: 45.0000px;
+ height: 25.98076211353316px;
+ -webkit-transform: rotate(45deg) scaleY(1.7321) translateY(-12.9904px);
+ -ms-transform: rotate(45deg) scaleY(1.7321) translateY(-12.9904px);
+ transform: rotate(45deg) scaleY(1.7321) translateY(-12.9904px);
+ -webkit-transform-origin: 0 0;
+ -ms-transform-origin: 0 0;
+ transform-origin: 0 0;
+ background: inherit;
+ }
+
+ .hexTop {
+ top: -15.9099px;
+ }
+
+ .hexTop:after {
+ background-position: center top;
+ }
+
+ .hexBottom {
+ bottom: -15.9099px;
+ }
+
+ .hexBottom:after {
+ background-position: center bottom;
+ }
+
+ &:after {
+ content: "";
+ position: absolute;
+ top: 0.0000px;
+ left: 0;
+ width: 45.0000px;
+ height: 25.9808px;
+ z-index: 2;
+ background: inherit;
+ }
+}
+
+.hexagon-60 {
+ position: relative;
+ width: 60px;
+ height: 34.64px;
+ margin: 17.32px 0;
+ background-size: auto 69.2820px;
+ background-position: center;
+
+ .hexTop,
+ .hexBottom {
+ position: absolute;
+ z-index: 1;
+ width: 42.43px;
+ height: 42.43px;
+ overflow: hidden;
+ -webkit-transform: scaleY(0.5774) rotate(-45deg);
+ -ms-transform: scaleY(0.5774) rotate(-45deg);
+ transform: scaleY(0.5774) rotate(-45deg);
+ background: inherit;
+ left: 8.79px;
+
+ /* Keeps borders smooth in webkit */
+ backface-visibility: hidden;
+ }
+
+ /*counter transform the bg image on the caps*/
+ .hexTop:after,
+ .hexBottom:after {
+ content: "";
+ position: absolute;
+ width: 60.0000px;
+ height: 34.64101615137755px;
+ -webkit-transform: rotate(45deg) scaleY(1.7321) translateY(-17.3205px);
+ -ms-transform: rotate(45deg) scaleY(1.7321) translateY(-17.3205px);
+ transform: rotate(45deg) scaleY(1.7321) translateY(-17.3205px);
+ -webkit-transform-origin: 0 0;
+ -ms-transform-origin: 0 0;
+ transform-origin: 0 0;
+ background: inherit;
+ }
+
+ .hexTop {
+ top: -21.2132px;
+ }
+
+ .hexTop:after {
+ background-position: center top;
+ }
+
+ .hexBottom {
+ bottom: -21.2132px;
+ }
+
+ .hexBottom:after {
+ background-position: center bottom;
+ }
+
+ &:after {
+ content: "";
+ position: absolute;
+ top: 0.0000px;
+ left: 0;
+ width: 60.0000px;
+ height: 34.6410px;
+ z-index: 2;
+ background: inherit;
+ }
+}
+
+.hexagon-80 {
+ position: relative;
+ width: 81px; // was 80, 81 seems to look better
+ height: 46.19px;
+ margin: 23.09px 0;
+ background-image: url(http://csshexagon.com/img/meow.jpg);
+ background-size: auto 92.3760px;
+ background-position: center;
+
+ .hexTop,
+ .hexBottom {
+ position: absolute;
+ z-index: 1;
+ width: 56.57px;
+ height: 56.57px;
+ overflow: hidden;
+ -webkit-transform: scaleY(0.5774) rotate(-45deg);
+ -ms-transform: scaleY(0.5774) rotate(-45deg);
+ transform: scaleY(0.5774) rotate(-45deg);
+ background: inherit;
+ left: 11.72px;
+ }
+
+ /*counter transform the bg image on the caps*/
+ .hexTop:after,
+ .hexBottom:after {
+ content: "";
+ position: absolute;
+ width: 80.0000px;
+ height: 46.188021535170066px;
+ -webkit-transform: rotate(45deg) scaleY(1.7321) translateY(-23.0940px);
+ -ms-transform: rotate(45deg) scaleY(1.7321) translateY(-23.0940px);
+ transform: rotate(45deg) scaleY(1.7321) translateY(-23.0940px);
+ -webkit-transform-origin: 0 0;
+ -ms-transform-origin: 0 0;
+ transform-origin: 0 0;
+ background: inherit;
+
+ /* Keeps borders smooth in webkit */
+ backface-visibility: hidden;
+ }
+
+ .hexTop {
+ top: -28.2843px;
+ }
+
+ .hexTop:after {
+ background-position: center top;
+ }
+
+ .hexBottom {
+ bottom: -28.2843px;
+ }
+
+ .hexBottom:after {
+ background-position: center bottom;
+ }
+
+ &:after {
+ content: "";
+ position: absolute;
+ top: 0.0000px;
+ left: 0;
+ width: 80.0000px;
+ height: 46.1880px;
+ z-index: 2;
+ background: inherit;
+ }
+}
+
+.hexagon-200 {
+ position: relative;
+ width: 200px;
+ height: 115.47px;
+ margin: 57.74px 0;
+ background-image: url(http://csshexagon.com/img/meow.jpg);
+ background-size: auto 230.9401px;
+ background-position: center;
+
+ .hexTop,
+ .hexBottom {
+ position: absolute;
+ z-index: 1;
+ width: 141.42px;
+ height: 141.42px;
+ overflow: hidden;
+ -webkit-transform: scaleY(0.5774) rotate(-45deg);
+ -ms-transform: scaleY(0.5774) rotate(-45deg);
+ transform: scaleY(0.5774) rotate(-45deg);
+ background: inherit;
+ left: 29.29px;
+ }
+
+ /*counter transform the bg image on the caps*/
+ .hexTop:after,
+ .hexBottom:after {
+ content: "";
+ position: absolute;
+ width: 200.0000px;
+ height: 115.47005383792516px;
+ -webkit-transform: rotate(45deg) scaleY(1.7321) translateY(-57.7350px);
+ -ms-transform: rotate(45deg) scaleY(1.7321) translateY(-57.7350px);
+ transform: rotate(45deg) scaleY(1.7321) translateY(-57.7350px);
+ -webkit-transform-origin: 0 0;
+ -ms-transform-origin: 0 0;
+ transform-origin: 0 0;
+ background: inherit;
+
+ /* Keeps borders smooth in webkit */
+ backface-visibility: hidden;
+ }
+
+ .hexTop {
+ top: -70.7107px;
+ }
+
+ .hexTop:after {
+ background-position: center top;
+ }
+
+ .hexBottom {
+ bottom: -70.7107px;
+ }
+
+ .hexBottom:after {
+ background-position: center bottom;
+ }
+
+ &:after {
+ content: "";
+ position: absolute;
+ top: 0.0000px;
+ left: 0;
+ width: 200.0000px;
+ height: 115.4701px;
+ z-index: 2;
+ background: inherit;
+ }
+}
+
+.hexagon-275 {
+ position: relative;
+ width: 275px;
+ height: 158.77px;
+ margin: 79.39px 0;
+ background-size: auto 317.5426px;
+ background-position: center;
+
+
+ &:after {
+ content: "";
+ position: absolute;
+ top: 0.0000px;
+ left: 0;
+ width: 275.0000px;
+ height: 158.7713px;
+ z-index: 2;
+ background: inherit;
+ }
+
+ .hexTop,
+ .hexBottom {
+ position: absolute;
+ z-index: 1;
+ width: 194.45px;
+ height: 194.45px;
+ overflow: hidden;
+ -webkit-transform: scaleY(0.5774) rotate(-45deg);
+ -ms-transform: scaleY(0.5774) rotate(-45deg);
+ transform: scaleY(0.5774) rotate(-45deg);
+ background: inherit;
+ left: 40.27px;
+
+ /* Keeps borders smooth in webkit */
+ backface-visibility: hidden;
+ }
+
+ /*counter transform the bg image on the caps*/
+ .hexTop:after,
+ .hexBottom:after {
+ content: "";
+ position: absolute;
+ width: 275.0000px;
+ height: 158.7713240271471px;
+ -webkit-transform: rotate(45deg) scaleY(1.7321) translateY(-79.3857px);
+ -ms-transform: rotate(45deg) scaleY(1.7321) translateY(-79.3857px);
+ transform: rotate(45deg) scaleY(1.7321) translateY(-79.3857px);
+ -webkit-transform-origin: 0 0;
+ -ms-transform-origin: 0 0;
+ transform-origin: 0 0;
+ background: inherit;
+ }
+
+ .hexTop {
+ top: -98.0272px; // tweaked value, original: -97.2272px;
+ }
+
+ .hexTop:after {
+ background-position: center top;
+ }
+
+ .hexBottom {
+ bottom: -97.0272px; //tweaked value, original: -97.2272px;
+ }
+
+ .hexBottom:after {
+ background-position: center bottom;
+ }
+}
+
+.user-hexagrid {
+ display: inline-block;
+ margin: 35px 0;
+
+ > div {
+ .user-hexagon {
+ display: inline-block;
+ opacity: 0.75;
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
+
+}
+
+.user-hexagrid-30 {
+ .user-hexagrid;
+
+ > div {
+ &:nth-child(even) {
+ margin-left: 15px;
+ }
+ .user-hexagon {
+ margin: -3px 1px -9px 0;
+ }
+ }
+}
+
+.user-hexagrid-60 {
+ .user-hexagrid;
+
+ > div {
+ &:nth-child(even) {
+ margin-left: 30px;
+ }
+ .user-hexagon {
+ margin: -11px 1px -11px 0;
+ }
+ }
+}
+
+.user-hexagrid-80 {
+ .user-hexagrid;
+
+ > div {
+ &:nth-child(even) {
+ margin-left: 41px;
+ }
+ .user-hexagon {
+ margin: -13px 1px -12px 1px;
+ }
+ }
+
+ .hovercard {
+ margin-top: 10px;
+ }
+}
+
+.user-hexagrid-200 {
+ .user-hexagrid;
+ margin: 70px 0;
+
+ > div {
+ &:nth-child(even) {
+ margin-left: 101px;
+ }
+ .user-hexagon {
+ margin: -30px 2px -29px 0;
+ }
+ }
+
+ .hovercard {
+ margin-top: 10px;
+ }
+}
+
+.user-hexagrid-275 {
+ .user-hexagrid;
+ margin: 70px 0;
+
+ > div {
+ &:nth-child(even) {
+ margin-left: 138px;
+ }
+ .user-hexagon {
+ margin: -41px 1px -41px 0;
+ }
+ }
+
+ .hovercard {
+ margin-top: 10px;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/hovercard.less b/ui/less/com/hovercard.less
new file mode 100644
index 0000000..9c48b4d
--- /dev/null
+++ b/ui/less/com/hovercard.less
@@ -0,0 +1,67 @@
+.hovercard {
+ position: absolute;
+ left: 0;
+ z-index: 10000;
+
+ padding: 20px;
+ white-space: pre;
+ background: #2D2D2D top left no-repeat;
+ background-size: contain;
+ padding-left: 130px;
+ color: #ccc;
+ box-shadow: 0px 3px 3px rgba(0,0,0,.85);
+
+ opacity: 0;
+ transition: opacity 0.15s ease-in-out 0.25s, top 0.15s ease-in-out 0.25s;
+ pointer-events: none;
+
+ h3 {
+ margin: 0 0 12px;
+ color: #5CBB1D;
+ }
+ p {
+ margin: 0;
+ }
+ p:last-child {
+ color: #fff;
+ }
+}
+
+.layout-rightnav .hovercard {
+ // orient for right side of the page
+ left: auto;
+ right: 0;
+}
+
+.user-hexagon {
+ position: relative;
+ .hovercard {
+ top: 65px;
+ }
+ &:hover .hovercard {
+ opacity: 1;
+ top: 60px;
+ }
+}
+
+.user-img {
+ position: relative;
+ .hovercard {
+ top: 55px;
+ }
+ &:hover .hovercard {
+ opacity: 1;
+ top: 50px;
+ }
+}
+
+.user-link-outer {
+ position: relative;
+ .hovercard {
+ top: 25px;
+ }
+ &:hover .hovercard {
+ opacity: 1;
+ top: 20px;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/image-uploader.less b/ui/less/com/image-uploader.less
new file mode 100644
index 0000000..87524a4
--- /dev/null
+++ b/ui/less/com/image-uploader.less
@@ -0,0 +1,12 @@
+.image-uploader {
+ .image-uploader-existing {
+ margin-bottom: 12px;
+ }
+ .image-uploader-editor {
+ width: 277px;
+ color: #555;
+ canvas {
+ border: 1px solid #aaa;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/images-form.less b/ui/less/com/images-form.less
new file mode 100644
index 0000000..2d8b897
--- /dev/null
+++ b/ui/less/com/images-form.less
@@ -0,0 +1,33 @@
+.images-form {
+ .images-form-list {
+ &:empty:before {
+ content: 'Click "Add image" to start a new album';
+ cursor: pointer;
+ background: #ddd;
+ border-radius: 5px;
+ padding: 40px;
+ text-align: center;
+ color: gray;
+ display: block;
+ margin-bottom: 6px;
+ }
+ .image {
+ display: flex;
+ margin: 0 0 6px;
+
+ .image-img {
+ flex: 0 0 128px;
+ img {
+ width: 100%;
+ }
+ }
+ .image-ctrls {
+ flex: 1;
+ padding: 0 0 0 10px;
+ p {
+ font-size: 16px;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/invite-form.less b/ui/less/com/invite-form.less
new file mode 100644
index 0000000..e042775
--- /dev/null
+++ b/ui/less/com/invite-form.less
@@ -0,0 +1,30 @@
+.invite-form {
+ .modal-form;
+
+ .form-inline > p {
+ display: flex;
+ .form-control {
+ flex: 1;
+ margin-right: 10px;
+ }
+ }
+ .processing-info {
+ display: none;
+ margin: 30px 25px 60px;
+ .spinner {
+ margin: 0;
+ float: left;
+ }
+ p {
+ color: #666;
+ position: relative;
+ left: 50px;
+ top: 15px;
+ }
+ }
+ .error {
+ display: none;
+ margin: 30px;
+ font-size: 16px;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/lookup-code-form.less b/ui/less/com/lookup-code-form.less
new file mode 100644
index 0000000..cd3e73f
--- /dev/null
+++ b/ui/less/com/lookup-code-form.less
@@ -0,0 +1,11 @@
+.lookup-code-form {
+ .modal-form;
+
+ .code {
+ padding: 10px;
+ margin-bottom: 10px;
+ background: #fafafa;
+ border: 1px solid #ddd;
+ word-break: break-all;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/lookup-form.less b/ui/less/com/lookup-form.less
new file mode 100644
index 0000000..730f708
--- /dev/null
+++ b/ui/less/com/lookup-form.less
@@ -0,0 +1,38 @@
+.lookup-form {
+ .modal-form;
+
+ .form-inline > p {
+ display: flex;
+ .form-control {
+ flex: 1;
+ margin-right: 10px;
+ }
+ }
+ .processing-info {
+ display: none;
+ margin: 30px 25px 60px;
+ .spinner {
+ margin: 0;
+ float: left;
+ }
+ p {
+ color: #666;
+ position: relative;
+ left: 50px;
+ top: 15px;
+ }
+ }
+ .error {
+ display: none;
+ margin: 30px;
+ font-size: 16px;
+ }
+
+ .code {
+ padding: 10px;
+ margin-bottom: 10px;
+ background: #fafafa;
+ border: 1px solid #ddd;
+ word-break: break-all;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/message-feed.less b/ui/less/com/message-feed.less
new file mode 100644
index 0000000..a57c45e
--- /dev/null
+++ b/ui/less/com/message-feed.less
@@ -0,0 +1,10 @@
+.message-feed-ctrls {
+ padding: 0.5em 0;
+ position: relative;
+}
+
+.message-feed-container {
+ .message-feed {
+ margin: 0 auto;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/message-oneline.less b/ui/less/com/message-oneline.less
new file mode 100644
index 0000000..9679469
--- /dev/null
+++ b/ui/less/com/message-oneline.less
@@ -0,0 +1,94 @@
+.message-oneline {
+ display: flex;
+ flex-direction: row;
+ border-top: 1px solid #ccc;
+
+ &.unread {
+ background: #fff;
+ font-weight: bold;
+ }
+
+ .message-oneline-column {
+ padding: 6px;
+
+ &:nth-child(1) {
+ flex: 0 0 40px;
+ padding-left: 15px;
+ }
+ &:nth-child(2) {
+ flex: 0 0 115px;
+ }
+ &:nth-child(3) {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+ a {
+ color: #444;
+ }
+ }
+ &:nth-child(4) {
+ flex: 0 0 80px;
+ text-align: right;
+ padding-right: 15px;
+ }
+ }
+
+ .user-img {
+ img {
+ width: 20px;
+ height: 20px;
+ border-radius: 10px;
+ }
+ }
+}
+
+.message-oneline-menuitem {
+ display: flex;
+ flex-direction: row;
+ border-top: 1px solid #ccc;
+ padding: 4px;
+ background: #eee;
+ color: #444;
+
+ &.unread {
+ background: #fff;
+ font-weight: bold;
+ }
+
+ .message-oneline-column {
+ padding: 4px;
+ text-align: left;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &:nth-child(1) {
+ flex: 0 0 50px;
+ }
+ &:nth-child(2), &.only {
+ flex: 1;
+ }
+ &:nth-child(3) {
+ flex: 0 0 60px;
+ text-align: right;
+ }
+
+ a {
+ color: #444;
+ text-decoration: none;
+ }
+ }
+
+ &:hover {
+ background: @brand-primary;
+ color: #fff;
+ text-decoration: none;
+ .message-oneline-column a {
+ color: #fff;
+ }
+ .text-muted {
+ color: #eee;
+ }
+ }
+}
diff --git a/ui/less/com/message-stats.less b/ui/less/com/message-stats.less
new file mode 100644
index 0000000..3eb01f7
--- /dev/null
+++ b/ui/less/com/message-stats.less
@@ -0,0 +1,73 @@
+.message-stats {
+ margin-bottom: 8px;
+ span, a {
+ color: #8899a6;
+ }
+ overflow: hidden;
+
+ .glyphicon {
+ color: #ccd6dd;
+ position: relative;
+ }
+ .selected .glyphicon {
+ color: darken(#8899a6, 5%);
+ }
+ .stat {
+ display: inline-block;
+ padding: 0 28px 0 0;
+ &:last-child {
+ padding: 0;
+ }
+ .glyphicon-comment {
+ font-size: 16px;
+ top: 3px;
+ }
+ .glyphicon-triangle-top {
+ font-size: 18px;
+ top: 4px;
+ }
+ .glyphicon-triangle-bottom {
+ font-size: 18px;
+ top: 4px;
+ }
+ &:hover .glyphicon {
+ color: darken(#8899a6, 5%);
+ }
+ }
+ .vote-tally:after {
+ content: attr(data-amt);
+ padding: 0 5px;
+ }
+ .comments:after {
+ content: attr(data-amt) " comments";
+ padding: 0 5px;
+ }
+ .user-hexagrid-30 {
+ margin: 1em 0 0;
+ border: 1px solid #ccc;
+ padding: 5px 5px 0;
+ background: #fafafa;
+ .user-hexagon {
+ margin: -9px 1px -3px 0;
+ }
+ }
+ .upvoters {
+ position: relative;
+ &:after {
+ content: ' +1';
+ position: absolute;
+ right: -22px;
+ top: 5px;
+ }
+ }
+ .downvoters {
+ float: right;
+ position: relative;
+ &:after {
+ content: '-1';
+ position: absolute;
+ left: -22px;
+ top: 5px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/message-summary.less b/ui/less/com/message-summary.less
new file mode 100644
index 0000000..918ea27
--- /dev/null
+++ b/ui/less/com/message-summary.less
@@ -0,0 +1,45 @@
+.message-summary {
+ margin: 5px 36px;
+
+ & > .user-img {
+ img {
+ width: 30px;
+ height: 30px;
+ border-radius: 15px;
+ float: left;
+ margin: 4px;
+ }
+ .hovercard {
+ top: 35px;
+ right: auto;
+ left: -40px;
+ }
+ &:hover .hovercard {
+ opacity: 1;
+ top: 30px;
+ }
+ }
+
+ .message-summary-content {
+ padding: 10px 40px
+ }
+
+ .msg-link {
+ color: #666;
+ display: block;
+ padding: 12px 0;
+ }
+
+ .pretty-raw {
+ font-size: 15px;
+ td {
+ padding: 2px 7px;
+ vertical-align: top;
+ }
+ td:first-child {
+ text-align: right;
+ min-width: 150px;
+ color: #999;
+ }
+ }
+}
diff --git a/ui/less/com/message.less b/ui/less/com/message.less
new file mode 100644
index 0000000..6626a70
--- /dev/null
+++ b/ui/less/com/message.less
@@ -0,0 +1,162 @@
+.message {
+ position: relative;
+ margin: 0 auto;
+ word-break: break-word;
+
+ & > .user-img {
+ position: absolute;
+ left: 0px;
+ top: 0px;
+
+ img {
+ width: 88px;
+ height: 88px;
+ opacity: 0.9;
+ }
+ img:hover {
+ opacity: 1;
+ }
+ }
+
+ &.secret {
+ }
+
+ &.smallview {
+ cursor: pointer;
+ }
+
+ .message-inner {
+ margin-left: 88px;
+ background: #fff;
+ border-bottom: 1px solid #eee;
+ }
+ &.unread .message-inner {
+ background: #FCFFE3;
+ }
+
+ &.add-anim {
+ .message-inner {
+ transition: background 0.5s;
+ background: #EDFFAC;
+ }
+ }
+
+ .message-ctrls {
+ margin: 0;
+ background: #eee;
+ font-size: 12px;
+
+ a {
+ color: #666;
+ }
+ li {
+ padding: 7px 10px;
+ }
+ }
+ .message-header,
+ .message-footer {
+ margin: 0;
+ height: 34px;
+ padding-left: 3px;
+
+ a {
+ color: #666;
+ }
+ li {
+ padding: 7px 10px;
+ }
+ .glyphicon {
+ font-size: 18px;
+ color: #bbb;
+ &.glyphicon-lock {
+ font-size: 12px;
+ }
+ }
+ a:hover .glyphicon {
+ color: #aaa;
+ }
+ .favorite {
+ padding-right: 0;
+ a.selected .glyphicon-star {
+ color: rgb(255, 194, 0);
+ }
+ a.selected:hover .glyphicon-star {
+ color: darken(rgb(255, 194, 0), 10%);
+ }
+
+ .users {
+ margin-right: 5px;
+ .user-img {
+ margin-right: 5px;
+ }
+ img {
+ width: 20px;
+ height: 20px;
+ border-radius: 10px;
+ vertical-align: top;
+ }
+ span {
+ color: #555;
+ position: relative;
+ top: -5px;
+ font-size: 14px;
+ margin: 5px;
+ }
+ }
+ }
+ }
+ .message-footer a {
+ color: gray;
+ }
+ .message-body {
+ padding: 6px 24px;
+
+ & > * {
+ margin: 0;
+ }
+ }
+ .markdown {
+ & > :first-child {
+ margin-top: 0;
+ }
+ & > :last-child {
+ margin-bottom: 0;
+ }
+ ul {
+ padding-left: 24px;
+ }
+ img {
+ max-width: 424px;
+ }
+ }
+ .message-mentions {
+ background: #fafafa;
+ padding: 8px 24px;
+ box-shadow: inset 0px 2px 2px rgba(0,0,0,0.1);
+ a {
+ color: gray;
+ }
+ }
+
+ .message-comments {
+ &:empty {
+ display: none;
+ }
+ }
+ .composer-header {
+ margin-left: 88px;
+ max-width: 100%;
+ }
+ .pretty-raw {
+ font-size: 15px;
+ td {
+ padding: 2px 7px;
+ vertical-align: top;
+ }
+ td:first-child {
+ text-align: right;
+ min-width: 150px;
+ color: #999;
+ }
+ }
+}
diff --git a/ui/less/com/modal-form.less b/ui/less/com/modal-form.less
new file mode 100644
index 0000000..cfeb9eb
--- /dev/null
+++ b/ui/less/com/modal-form.less
@@ -0,0 +1,13 @@
+.modal-form {
+ background: #fff;
+ padding: 14px 15px;
+ border-radius: 2px;
+ max-width: 600px;
+ margin: 10px auto;
+
+ h3 {
+ margin-top: 0;
+ color: #555;
+ font-size: 22px;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/modal.less b/ui/less/com/modal.less
new file mode 100644
index 0000000..d15f2a1
--- /dev/null
+++ b/ui/less/com/modal.less
@@ -0,0 +1,15 @@
+.modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1000;
+ overflow-y: scroll;
+ background: rgba(0,0,0,0.5);
+
+ .modal-inner {
+ margin: 0 auto;
+ max-width: 600px;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/notifications.less b/ui/less/com/notifications.less
new file mode 100644
index 0000000..1d25e35
--- /dev/null
+++ b/ui/less/com/notifications.less
@@ -0,0 +1,25 @@
+.notifications {
+ .note {
+ margin: 0 32px;
+ &.warning {
+ color: #444;
+ background: #fff;
+ padding: 10px 15px 10px;
+ border: 1px solid #ccc;
+
+ h3 {
+ font-weight: bold;
+ margin: 0 0 3px;
+ color: #D71611;
+ }
+ ul.list-inline {
+ margin-bottom: 2px;
+ }
+ }
+ .user-img img {
+ width: 20px;
+ height: 20px;
+ border-radius: 10px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/page-nav.less b/ui/less/com/page-nav.less
new file mode 100644
index 0000000..5419f6f
--- /dev/null
+++ b/ui/less/com/page-nav.less
@@ -0,0 +1,143 @@
+#page-nav {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 38px;
+ width: 100%;
+ z-index: 100;
+
+ white-space: nowrap;
+ font-size: 15px;
+ font-weight: 100;
+ padding: 6px;
+ background: linear-gradient(to bottom, #eee, #ddd);
+ border-bottom: 1px solid #aaa;
+
+ .page-nav-inner {
+ display: flex;
+ flex-direction: row;
+ height: 25px;
+
+ & > a {
+ text-align: center;
+ text-decoration: none;
+ padding-top: 3px;
+ color: rgb(100, 98, 98);
+ cursor: pointer;
+
+ &:hover, &.selected {
+ text-decoration: none;
+ color: @brand-primary;
+ }
+ }
+ }
+ a.disabled, a.disabled:hover {
+ color: #aaa;
+ cursor: default;
+ }
+
+ .spacer {
+ flex: 1;
+ }
+
+ a.button {
+ position: relative;
+ flex: 0 0 30px;
+ height: 25px;
+ }
+
+ a.action {
+ position: relative;
+ top: -2px;
+ height: 29px;
+ padding: 3px 7px;
+ margin-right: 8px;
+ border-radius: 3px;
+ background: linear-gradient(to bottom, #F2F2F2, #E4E2E2 7%, #DAD9D9 97%, #C2C0C0);
+ border: 1px solid #ccc;
+ }
+
+ a.stat {
+ position: relative;
+ height: 26px;
+ padding: 2px 5px 0;
+
+ .glyphicon {
+ margin-right: 2px;
+ }
+ .glyphicon-envelope, .glyphicon-user {
+ font-size: 14px;
+ }
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+
+ .unread {
+ position: absolute;
+ top: 0;
+ left: 0;
+ color: #FFFFFF;
+ background: @brand-primary;
+ font-size: 9px;
+ border-radius: 3px;
+ padding: 0 4px;
+ font-weight: normal;
+ }
+ a:hover .unread {
+ background: lighten(@brand-primary, 10%);
+ }
+
+ a.profile {
+ width: 40px;
+ position: relative;
+ top: -7px;
+ margin-right: 0;
+
+ img {
+ width: 30px;
+ height: 30px;
+ border-radius: 5px;
+ box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.35);
+ }
+ }
+
+ .divider {
+ width: 1px;
+ border-left: 1px solid #ccc;
+ margin: 0 10px 0 2px;
+ }
+
+ input {
+ position: relative;
+ top: -3px;
+ flex: 1;
+ height: 31px;
+ padding: 0 7px;
+ margin: 0 5px;
+ font-size: 14px;
+ color: #808080;
+ outline: 0;
+ box-shadow: inset 0px 1px 2px rgba(0, 0, 0, 0.2);
+ background: #fff;
+ border: 1px solid #bbb;
+
+ &:focus, &:active, &:hover {
+ outline: 0;
+ box-shadow: inset 0px 1px 2px rgba(0, 0, 0, 0.2);
+ background: #fff;
+ border: 1px solid #bbb;
+ }
+ }
+
+ .address-icon {
+ position: relative;
+ .glyphicon {
+ position: absolute;
+ right: 20px;
+ top: 6px;
+ color: #666;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/peers.less b/ui/less/com/peers.less
new file mode 100644
index 0000000..de5691a
--- /dev/null
+++ b/ui/less/com/peers.less
@@ -0,0 +1,36 @@
+.peers {
+ max-width: 840px;
+ margin: 0 auto 15px;
+
+ .peer {
+ display: flex;
+ background-color: #fff;
+ height: 110px;
+ padding: 5px 10px;
+ margin: 10px 0;
+ border-radius: 5px;
+
+ .user-hexagon {
+ margin-top: 5px;
+ margin-right: 15px;
+ }
+ .details {
+ flex: 1;
+ h3 {
+ margin-top: 8px;
+ }
+ }
+
+ .progress {
+ display: none;
+ }
+ &.connected {
+ .progress {
+ display: block;
+ }
+ .last-connect {
+ display: none;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/pm-form.less b/ui/less/com/pm-form.less
new file mode 100644
index 0000000..c5a318a
--- /dev/null
+++ b/ui/less/com/pm-form.less
@@ -0,0 +1,94 @@
+.pm-form {
+ .pm-form-recipients {
+ display: flex;
+ flex-wrap: wrap;
+ height: auto;
+ min-height: 34px;
+ border-bottom: 1px solid #ccc;
+ padding: 8px 10px 2px;
+
+ .recp-label {
+ color: #999;
+ margin-right: 10px;
+ }
+
+ .recp {
+ display: inline-block;
+ border: 1px solid #eee;
+ border-radius: 2px;
+ padding: 0 3px;
+ margin-left: 5px;
+ margin-bottom: 5px;
+ }
+
+ input {
+ border: 0;
+ flex: 1;
+ padding-left: 5px;
+ margin-bottom: 5px;
+ min-width: 50px;
+ &:focus {
+ outline: 0;
+ }
+ }
+ }
+ .pm-form-subject {
+ border-bottom: 1px solid #ccc;
+ input {
+ padding: 8px 10px 8px;
+ border: 0;
+ width: 100%;
+ &:focus {
+ outline: 0;
+ }
+ }
+ }
+ .pm-form-textarea {
+ textarea {
+ display: block;
+ width: 100%;
+ height: 325px;
+ padding: 8px 10px;
+ font-size: @font-size-base;
+ line-height: @line-height-base;
+ color: @input-color;
+ background-image: none;
+
+ border: 0;
+ outline: none;
+ resize: none;
+
+ // Placeholder
+ .placeholder(@input-color-placeholder);
+ }
+ }
+ .pm-form-attachments {
+ position: relative; // for the position absolute postbtn
+ border-bottom-left-radius: @input-border-radius;
+ border-bottom-right-radius: @input-border-radius;
+ padding: 10px 5px 6px;
+ color: #555;
+ font-size: 12px;
+
+ ul {
+ padding-left: 20px;
+
+ &:empty {
+ display: none;
+ }
+ }
+ & > a {
+ padding: 5px;
+ }
+
+ .postbtn {
+ position: absolute;
+ bottom: 1px;
+ right: 3px;
+ height: 34px;
+ color: #fff;
+ background: @brand-primary;
+ font-weight: 100;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/post-form.less b/ui/less/com/post-form.less
new file mode 100644
index 0000000..f4322ee
--- /dev/null
+++ b/ui/less/com/post-form.less
@@ -0,0 +1,81 @@
+.post-form {
+ .post-form-textarea {
+ textarea {
+ display: block;
+ width: 100%;
+ padding: 6px 12px;
+ font-size: @font-size-base;
+ line-height: @line-height-base;
+ color: @input-color;
+ background-image: none;
+
+ border-radius: @input-border-radius;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ outline: none;
+
+ position: relative;
+ z-index: 1; // put higher than attachments bar below so that box shadow covers it
+
+ // Placeholder
+ .placeholder(@input-color-placeholder);
+
+ &.short {
+ border-radius: 2px;
+ border-bottom-width: 1px;
+ }
+ }
+ }
+ .post-form-preview {
+ background: rgba(255, 255, 255, 0.5);
+ padding: 10px 12px;
+ margin-bottom: 5px;
+ border-top: 1px dashed @input-border;
+ border-radius: @input-border-radius;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ &:empty {
+ padding: 0;
+ border-width: 0;
+ }
+ & > :first-child {
+ margin-top: 0;
+ }
+ & > :last-child {
+ margin-bottom: 0;
+ }
+ }
+ .post-form-attachments {
+ position: relative; // for the position absolute postbtn
+ padding: 6px;
+ color: #555;
+ font-size: 12px;
+ height: 40px;
+
+ ul {
+ padding-left: 20px;
+
+ &:empty {
+ display: none;
+ }
+ }
+ & > a {
+ padding: 5px;
+ }
+
+ .postbtn {
+ position: absolute;
+ top: 1px;
+ right: 1px;
+ height: 34px;
+ color: #fff;
+ background: @brand-primary;
+ font-weight: 100;
+ }
+ }
+}
+
+.modal .post-form {
+ background: #fff;
+ padding: 2px 10px 12px;
+}
diff --git a/ui/less/com/program-editor.less b/ui/less/com/program-editor.less
new file mode 100644
index 0000000..d301b82
--- /dev/null
+++ b/ui/less/com/program-editor.less
@@ -0,0 +1,124 @@
+.editor-container {
+ position: relative;
+ .editor-ctrls {
+ position: absolute;
+ z-index: 500;
+ right: 10px;
+ top: 6px;
+ text-align: right;
+
+ a {
+ display: inline-block;
+ padding: 6px 15px;
+ background: #44453D;
+ color: #A6B1BE;
+ border-radius: 2px;
+ border: 1px solid transparent;
+ box-shadow: 0 0 3px rgba(0,0,0,1);
+ margin-left: 5px;
+
+ &.highlighted {
+ border-color: #154F9B;
+ color: #2778e2;
+ }
+
+ &:hover {
+ color: #747D88;
+ text-decoration: none;
+ background: #3F4137;
+ box-shadow: 0 0 15px rgba(0,0,0,1);
+ border-color: transparent;
+ &.blue {
+ background: @blue-primary;
+ color: @blue-tertiary;
+ }
+ &.yellow {
+ background: @yellow-primary;
+ color: @yellow-secondary;
+ }
+ }
+ }
+ }
+}
+
+.editor-nav {
+ margin: 2px 0 8px;
+
+ .navlinks {
+ display: table-cell;
+ white-space: pre;
+ a {
+ position: relative;
+ display: inline-block;
+ padding: 6px 22px 0 19px;
+ font-weight: 100;
+ color: #555;
+ margin-right: 5px;
+ border: 1px solid transparent;
+
+ &:hover {
+ text-decoration: none;
+ background: #ddd;
+ }
+ &.selected {
+ border-color: #ccc;
+ background: #fff;
+
+ // hide the border under the selected item
+ &:after {
+ content: '';
+ display: block;
+ position: absolute;
+ bottom: -2px;
+ left: 0;
+ width: 100%;
+ height: 4px;
+ background: #fff;
+ }
+ }
+ }
+ }
+ .editor-nav-body {
+ background: #fff;
+ border: 1px solid #ccc;
+ padding: 0 5px;
+ p {
+ margin: 5px 0;
+ }
+ input.form-control {
+ height: 29px;
+ padding: 5px;
+ }
+ .btn-default {
+ background: #eee;
+ &:hover {
+ background: #ddd;
+ }
+ }
+ .buffer-list {
+ list-style: none;
+ padding: 0;
+ margin: 5px 0;
+ font-size: 13px;
+ border: 1px solid #ccc;
+ overflow-y: scroll;
+ max-height: 500px;
+
+ li {
+ background: #eee;
+ padding: 5px 5px 3px;
+ display: block;
+ a {
+ color: #666;
+ }
+ &.selected {
+ background: #666;
+ background: linear-gradient(to bottom, #888 0%, #555 100%);
+ a {
+ color: #eee;
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/program-feed.less b/ui/less/com/program-feed.less
new file mode 100644
index 0000000..c13b022
--- /dev/null
+++ b/ui/less/com/program-feed.less
@@ -0,0 +1,17 @@
+.program-feed-container {
+ border: 1px solid #ccc;
+ background: #fff;
+
+ .program-feed {
+ width: 100%;
+
+ &:empty::before {
+ content: 'Empty';
+ font-size: 16px;
+ font-style: italic;
+ color: #777;
+ margin: 1em;
+ display: block;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/program-summary.less b/ui/less/com/program-summary.less
new file mode 100644
index 0000000..eff16f3
--- /dev/null
+++ b/ui/less/com/program-summary.less
@@ -0,0 +1,11 @@
+.program-summary {
+
+ &:nth-child(odd) {
+ .profpic .hexagon-60 {
+ margin-left: 32px;
+ }
+ td {
+ background: #f5f5f5;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/rename-form.less b/ui/less/com/rename-form.less
new file mode 100644
index 0000000..2daccd6
--- /dev/null
+++ b/ui/less/com/rename-form.less
@@ -0,0 +1,10 @@
+.rename-form {
+ .modal-form;
+ .form-inline > p {
+ display: flex;
+ .form-control {
+ flex: 1;
+ margin-right: 10px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/search.less b/ui/less/com/search.less
new file mode 100644
index 0000000..60cbaa2
--- /dev/null
+++ b/ui/less/com/search.less
@@ -0,0 +1,6 @@
+.search {
+ display: block;
+ width: 100%;
+ padding: 7px 10px;
+ .placeholder;
+}
\ No newline at end of file
diff --git a/ui/less/com/sidenav.less b/ui/less/com/sidenav.less
new file mode 100644
index 0000000..4ec9315
--- /dev/null
+++ b/ui/less/com/sidenav.less
@@ -0,0 +1,61 @@
+.sidenav {
+ margin-top: 8px;
+ a {
+ color: #666;
+ }
+ h4 {
+ margin: 0;
+ color: gray;
+ }
+ .pubstats {
+ font-size: 21px;
+ cursor: pointer;
+ padding: 5px;
+ margin-bottom: -5px;
+ &:hover {
+ background: #ddd;
+ }
+ p {
+ margin: 0;
+ }
+ a {
+ color: #333;
+ }
+ .name a {
+ font-weight: bold;
+ color: #333;
+ }
+ }
+ .view {
+ font-size: 15px;
+ small {
+ color: gray;
+ }
+ }
+ label {
+ input {
+ font-size: 16px;
+ }
+ top: 1px;
+ position: relative;
+ font-size: 15px;
+ color: #666;
+ font-weight: normal;
+ cursor: pointer;
+ }
+ .filters {
+ color: #555;
+ display: inline-block;
+ font-size: 15px;
+ label {
+ margin: 0 0 0 5px;
+ }
+ }
+ li {
+ margin-bottom: 5px;
+ }
+ hr {
+ border-color: #ddd;
+ margin: 30px 0;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/spinner.less b/ui/less/com/spinner.less
new file mode 100644
index 0000000..1b93cdf
--- /dev/null
+++ b/ui/less/com/spinner.less
@@ -0,0 +1,96 @@
+// http://tobiasahlin.com/spinkit/
+
+.spinner {
+ margin: 100px auto;
+ width: 32px;
+ height: 32px;
+ position: relative;
+
+ .cube1, .cube2 {
+ background-color: #333;
+ width: 10px;
+ height: 10px;
+ position: absolute;
+ top: 0;
+ left: 0;
+
+ -webkit-animation: cubemove 1.8s infinite ease-in-out;
+ animation: cubemove 1.8s infinite ease-in-out;
+ }
+
+ .cube2 {
+ -webkit-animation-delay: -0.9s;
+ animation-delay: -0.9s;
+ }
+
+ &.small {
+ width: 16px;
+ height: 16px;
+ .cube1, .cube2 {
+ width: 5px;
+ height: 5px;
+ -webkit-animation: cubemove-small 1.8s infinite ease-in-out;
+ animation: cubemove-small 1.8s infinite ease-in-out;
+ }
+ .cube2 {
+ -webkit-animation-delay: -0.9s;
+ animation-delay: -0.9s;
+ }
+ }
+ &.inline {
+ margin: 0;
+ display: inline-block;
+ }
+}
+
+@-webkit-keyframes cubemove {
+ 25% { -webkit-transform: translateX(42px) rotate(-90deg) scale(0.5) }
+ 50% { -webkit-transform: translateX(42px) translateY(42px) rotate(-180deg) }
+ 75% { -webkit-transform: translateX(0px) translateY(42px) rotate(-270deg) scale(0.5) }
+ 100% { -webkit-transform: rotate(-360deg) }
+}
+
+@keyframes cubemove {
+ 25% {
+ transform: translateX(42px) rotate(-90deg) scale(0.5);
+ -webkit-transform: translateX(42px) rotate(-90deg) scale(0.5);
+ } 50% {
+ transform: translateX(42px) translateY(42px) rotate(-179deg);
+ -webkit-transform: translateX(42px) translateY(42px) rotate(-179deg);
+ } 50.1% {
+ transform: translateX(42px) translateY(42px) rotate(-180deg);
+ -webkit-transform: translateX(42px) translateY(42px) rotate(-180deg);
+ } 75% {
+ transform: translateX(0px) translateY(42px) rotate(-270deg) scale(0.5);
+ -webkit-transform: translateX(0px) translateY(42px) rotate(-270deg) scale(0.5);
+ } 100% {
+ transform: rotate(-360deg);
+ -webkit-transform: rotate(-360deg);
+ }
+}
+
+@-webkit-keyframes cubemove-small {
+ 25% { -webkit-transform: translateX(21px) rotate(-90deg) scale(0.5) }
+ 50% { -webkit-transform: translateX(21px) translateY(21px) rotate(-180deg) }
+ 75% { -webkit-transform: translateX(0px) translateY(21px) rotate(-270deg) scale(0.5) }
+ 100% { -webkit-transform: rotate(-360deg) }
+}
+
+@keyframes cubemove-small {
+ 25% {
+ transform: translateX(21px) rotate(-90deg) scale(0.5);
+ -webkit-transform: translateX(21px) rotate(-90deg) scale(0.5);
+ } 50% {
+ transform: translateX(21px) translateY(21px) rotate(-179deg);
+ -webkit-transform: translateX(21px) translateY(21px) rotate(-179deg);
+ } 50.1% {
+ transform: translateX(21px) translateY(21px) rotate(-180deg);
+ -webkit-transform: translateX(21px) translateY(21px) rotate(-180deg);
+ } 75% {
+ transform: translateX(0px) translateY(21px) rotate(-270deg) scale(0.5);
+ -webkit-transform: translateX(0px) translateY(21px) rotate(-270deg) scale(0.5);
+ } 100% {
+ transform: rotate(-360deg);
+ -webkit-transform: rotate(-360deg);
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/subwindow.less b/ui/less/com/subwindow.less
new file mode 100644
index 0000000..53d7a98
--- /dev/null
+++ b/ui/less/com/subwindow.less
@@ -0,0 +1,62 @@
+.subwindow {
+ position: fixed;
+ bottom: 0;
+ right: 10px;
+ width: 500px;
+ height: 440px;
+ z-index: 1000;
+
+ border: 1px solid #aaa;
+ background: #fff;
+ box-shadow: 0 0 10px rgba(0,0,0,0.35);
+
+ .subwindow-toolbar {
+ height: 40px;
+ background: #555;
+ padding: 4px;
+ text-align: center;
+ border-bottom: 1px solid #ccc;
+ display: flex;
+
+ .title {
+ flex: 1;
+ font-size: 16px;
+ color: #fff;
+ font-weight: 100;
+ text-align: left;
+ padding: 5px;
+ }
+
+ a {
+ flex: 0 0 40px;
+ display: block;
+ padding: 7px 0;
+ color: #ccc;
+
+ &:hover {
+ background: #666;
+ }
+ }
+ }
+
+ .subwindow-body {
+ flex: 1;
+ height: 400px;
+ overflow: auto;
+ }
+
+ &.collapsed {
+ width: 50px;
+ height: 42px;
+
+ .subwindow-toolbar {
+ border: 0;
+ .title, .help, .goto, .close {
+ display: none;
+ }
+ }
+ .subwindow-body {
+ display: none;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/suggest-box.less b/ui/less/com/suggest-box.less
new file mode 100644
index 0000000..1216935
--- /dev/null
+++ b/ui/less/com/suggest-box.less
@@ -0,0 +1,42 @@
+@import "../variables";
+
+.suggest-box {
+ position: fixed;
+ border: 1px solid #ddd;
+ z-index: 10000;
+ background: white;
+
+ ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ li {
+ padding: 4px 8px;
+ font-size: 85%;
+ border-bottom: 1px solid #ddd;
+ &:last-child {
+ border: 0;
+ }
+ &.selected {
+ color: #fff;
+ background-color: @brand-primary;
+ border-color: darken(@brand-primary, 5%);
+ }
+ img {
+ height: 20px;
+ }
+ &.user img {
+ border-radius: 10px;
+ }
+ }
+ }
+ &.msg-recipients {
+ ul li {
+ font-size: 16px;
+ img {
+ height: 24px;
+ border-radius: 12px;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/com/webcam-giffer-form.less b/ui/less/com/webcam-giffer-form.less
new file mode 100644
index 0000000..2eb985b
--- /dev/null
+++ b/ui/less/com/webcam-giffer-form.less
@@ -0,0 +1,29 @@
+.webcam-giffer-form {
+ display: flex;
+ .webcam-giffer-form-videos {
+ position: relative;
+ flex: 0 0 300px;
+ video {
+ cursor: pointer;
+ &.recording {
+ outline: 1px solid red;
+ }
+ }
+ .countdown {
+ position: absolute;
+ font-size: 72px;
+ left: 130px;
+ top: 30px;
+ font-weight: bold;
+ color: rgb(182, 0, 0);
+ }
+ }
+ .webcam-giffer-form-ctrls {
+ flex: 1;
+ padding: 0 3px;
+ textarea {
+ height: 150px;
+ margin-bottom: 5px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/layout.less b/ui/less/layout.less
new file mode 100644
index 0000000..14fdcd7
--- /dev/null
+++ b/ui/less/layout.less
@@ -0,0 +1,71 @@
+#page-container {
+ padding-top: 38px;
+}
+.layout-onecol {
+ max-width: 940px;
+ margin: 0 auto;
+ padding: 0 30px;
+ .layout-main {
+ .message-feed {
+ max-width: 560px;
+ }
+ }
+}
+.layout-twocol {
+ max-width: 940px;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: row;
+
+ .layout-main {
+ flex: 1;
+ padding: 0 30px;
+
+ .message-feed {
+ max-width: 560px;
+ }
+ }
+ .layout-rightnav {
+ flex: 0 0 315px;
+ }
+}
+.layout-threecol {
+ max-width: 1050px;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: row;
+
+ .layout-main {
+ flex: 1;
+ }
+ .layout-leftnav {
+ flex: 0 0 150px;
+ }
+ .layout-rightnav {
+ flex: 0 0 315px;
+ }
+}
+.layout-setup {
+ max-width: 640px;
+ margin: 0 auto;
+ padding: 0 20px;
+ display: flex;
+ flex-direction: row;
+
+ .layout-setup-left {
+ flex: 1;
+ }
+ .layout-setup-right {
+ flex: 1;
+ .layout-setup-right-inner {
+ padding: 40px 0 0 20px;
+ }
+ }
+}
+.layout-grid {
+ display: flex;
+
+ .layout-grid-col {
+ flex: 1;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/main.less b/ui/less/main.less
new file mode 100644
index 0000000..404a0f4
--- /dev/null
+++ b/ui/less/main.less
@@ -0,0 +1,294 @@
+// Core variables and mixins
+//@import "bootstrap/variables.less";
+@import "variables.less";
+@import "bootstrap/mixins.less";
+
+// Reset and dependencies
+@import "bootstrap/normalize.less";
+@import "bootstrap/print.less";
+@import "bootstrap/glyphicons.less";
+
+// Core CSS
+@import "bootstrap/scaffolding.less";
+@import "bootstrap/type.less";
+@import "bootstrap/code.less";
+@import "bootstrap/grid.less";
+@import "bootstrap/tables.less";
+@import "bootstrap/forms.less";
+@import "bootstrap/buttons.less";
+@import "layout.less";
+
+// Components
+// @import "bootstrap/component-animations.less";
+@import "bootstrap/dropdowns.less";
+@import "bootstrap/button-groups.less";
+// @import "bootstrap/input-groups.less";
+@import "bootstrap/navs.less";
+@import "bootstrap/navbar.less";
+// @import "bootstrap/breadcrumbs.less";
+// @import "bootstrap/pagination.less";
+// @import "bootstrap/pager.less";
+@import "bootstrap/labels.less";
+// @import "bootstrap/badges.less";
+@import "bootstrap/jumbotron.less";
+//@import "bootstrap/thumbnails.less";
+@import "bootstrap/alerts.less";
+@import "bootstrap/progress-bars.less";
+// @import "bootstrap/media.less";
+// @import "bootstrap/list-group.less";
+@import "bootstrap/panels.less";
+// @import "bootstrap/responsive-embed.less";
+// @import "bootstrap/wells.less";
+// @import "bootstrap/close.less";
+
+// Components w/ JavaScript
+// @import "bootstrap/modals.less";
+// @import "bootstrap/tooltip.less";
+// @import "bootstrap/popovers.less";
+// @import "bootstrap/carousel.less";
+
+// Utility classes
+@import "bootstrap/utilities.less";
+@import "bootstrap/responsive-utilities.less";
+
+// Components
+@import "com/dropdown.less";
+@import "com/page-nav.less";
+@import "com/sidenav.less";
+@import "com/spinner.less";
+@import "com/modal.less";
+@import "com/modal-form.less";
+@import "com/error-form.less";
+@import "com/subwindow.less";
+@import "com/address-book.less";
+@import "com/message.less";
+@import "com/message-summary.less";
+@import "com/message-oneline.less";
+@import "com/message-feed.less";
+@import "com/message-stats.less";
+@import "com/contact-summary.less";
+@import "com/contact-feed.less";
+@import "com/contact-listing.less";
+@import "com/contact-sync-listing.less";
+@import "com/files-view.less";
+@import "com/notifications.less";
+@import "com/header-controls.less";
+@import "com/peers.less";
+@import "com/composer.less";
+@import "com/post-form.less";
+@import "com/pm-form.less";
+@import "com/webcam-giffer-form.less";
+@import "com/images-form.less";
+@import "com/image-uploader.less";
+@import "com/invite-form.less";
+@import "com/lookup-form.less";
+@import "com/rename-form.less";
+@import "com/flag-form.less";
+@import "com/lookup-code-form.less";
+@import "com/suggest-box.less";
+@import "com/hexagon.less";
+@import "com/hovercard.less";
+@import "com/finder.less";
+
+// Pages
+@import "pages/home.less";
+@import "pages/profile.less";
+@import "pages/message.less";
+@import "pages/sync.less";
+@import "pages/feed.less";
+@import "pages/followers.less";
+@import "pages/webview.less";
+@import "pages/inbox.less";
+
+// Home styles
+body {
+ background: url(../img/lines.svg) #eee;
+}
+
+// General styles
+
+a[target=_blank]:not(.noicon):before {
+ content: ' ';
+ width: 14px;
+ height: 12px;
+ display: inline-block;
+ background: url(../img/external-link.svg) top left no-repeat transparent;
+}
+a.bad {
+ cursor: help;
+ color: red;
+ border-bottom: 1px dashed;
+}
+textarea, textarea.form-control {
+ border: 0;
+ box-shadow: none;
+}
+.user-link {
+ font-weight: bold;
+ color: #555;
+ &.thin {
+ font-weight: normal;
+ }
+}
+.monospace {
+ font-family: @font-family-monospace;
+}
+.well {
+ border: 1px solid #ccc;
+ padding: 1em;
+ border-radius: 2px;
+ &.white {
+ background: #fff;
+ }
+}
+.btn {
+ font-weight: 100;
+ &.btn-strong {
+ background: #fff;
+ &:hover {
+ color: #fff;
+ background: linear-gradient(to bottom, #71a800, #649304);
+ }
+ }
+}
+.btn-xs.btn-strong {
+ padding: 3px 5px 1px;
+}
+.btn-primary.btn-strong {
+ border: 1px solid @brand-primary;
+}
+.btn-success.btn-strong {
+ border: 1px solid @brand-success;
+}
+.btn-info.btn-strong {
+ border: 1px solid @brand-info;
+}
+.btn-warning.btn-strong {
+ border: 1px solid @brand-warning;
+}
+.btn-danger.btn-strong {
+ border: 1px solid @brand-danger;
+}
+.btn-action {
+ color: @brand-primary;
+ font-size: 16px;
+
+ &:hover {
+ color: darken(@brand-primary, 10%);
+ }
+
+ .glyphicon {
+ margin-right: 5px;
+ }
+}
+.btn.btn-3d {
+ border: 1px solid #ccc;
+ background: linear-gradient(to bottom, #fff, #fdfdfd 10%, #fff 90%, #e5e5e5);
+ border-radius: 0;
+ color: #6e6c6c;
+ font-size: 12px;
+ font-weight: bold;
+ &.btn-sm, &.btn-xs {
+ font-weight: normal;
+ }
+
+ &:hover {
+ color: #444;
+ }
+ &.pressed {
+ color: #FFFFFF;
+ background: rgb(67, 105, 255);
+ box-shadow: inset 0px 2px 3px rgba(0, 0, 0, 0.1);
+ border-color: rgb(74, 101, 208);
+ }
+}
+.alert-info {
+ background-color: #EFE4FA;
+ border-color: #8F94E9;
+ color: #5F61D0;
+ border-radius: 0;
+}
+.alert-success {
+ background-color: #D1FF81;
+ border-color: #77B550;
+ color: #49932B;
+ border-radius: 0;
+}
+.alert-warning {
+ background-color: #E4FF83;
+ border-color: #AB7D1D;
+ color: #917E32;
+ border-radius: 0;
+}
+.alert-danger {
+ background-color: #ECB9D7;
+ color: #8C5169;
+ border: 1px solid #B57991;
+ border-radius: 0;
+}
+#app-status {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ z-index: 10000;
+ width: auto;
+
+ div {
+ padding: 2px 10px;
+ background-color: rgb(201, 201, 201);
+ color: #555;
+ font-size: 16px;
+ font-weight: 100;
+ }
+}
+#app-notices {
+ position: fixed;
+ top: 15px;
+ left: 10px;
+ z-index: 10000;
+ width: auto;
+ .alert {
+ box-shadow: 0px 0px 6px rgba(0, 0, 0, 0.25);
+ }
+}
+#please-wait {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 9999;
+ background: rgba(0,0,0,0.1);
+}
+.welcome-help {
+ margin: 15px 32px 30px;
+
+ h2 {
+ text-align: center;
+ margin-top: 10px;
+ color: @blue-primary;
+ font-weight: bold;
+ }
+ .big-btn {
+ border: 1px solid #ccc;
+ border-radius: 2px;
+ padding: 10px 20px;
+ margin: 15px 0;
+ cursor: pointer;
+ background: linear-gradient(to bottom, #ffffff, #fdfdfd 10%, #ffffff 95%, #e5e5e5);
+ text-align: center;
+ color: #6e6c6c;
+ font-weight: bold;
+
+ h3 {
+ margin-top: 6px;
+ }
+
+ &:hover {
+ background: #fafafa;
+ }
+ }
+}
+.setup-form code {
+ background: #fff;
+}
\ No newline at end of file
diff --git a/ui/less/pages/feed.less b/ui/less/pages/feed.less
new file mode 100644
index 0000000..e2a0fe0
--- /dev/null
+++ b/ui/less/pages/feed.less
@@ -0,0 +1,8 @@
+.feed-page {
+ .layout-onecol .layout-main .message-feed {
+ max-width: 100%;
+ }
+ .message-summary-content {
+ background: #fff;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/pages/followers.less b/ui/less/pages/followers.less
new file mode 100644
index 0000000..74f3f05
--- /dev/null
+++ b/ui/less/pages/followers.less
@@ -0,0 +1,13 @@
+.followers-page {
+ .message-feed {
+ margin: 0;
+ &:empty:before {
+ content: 'Empty';
+ font-size: 16px;
+ font-style: italic;
+ color: #777;
+ margin: 1em;
+ display: block;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/pages/home.less b/ui/less/pages/home.less
new file mode 100644
index 0000000..89dfdbc
--- /dev/null
+++ b/ui/less/pages/home.less
@@ -0,0 +1,52 @@
+.home-page {
+ .shortcuts {
+ display: flex;
+ margin: 5px 0;
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 3px;
+ padding: 6px 0;
+ font-size: 21px;
+
+ a {
+ flex: 1;
+ color: gray;
+ padding: 0 12px;
+ text-align: center;
+ border-right: 1px solid #ccc;
+ &:last-child {
+ border: 0;
+ }
+ &.highlight {
+ color: @brand-primary;
+ }
+ }
+ }
+
+ .flagged-nsfw, .flagged-spam, .flagged-abuse {
+ display: none;
+ }
+ .show-nsfw .flagged-nsfw {
+ display: block;
+ }
+ .show-spam .flagged-spam {
+ display: block;
+ }
+ .show-abuse .flagged-abuse {
+ display: block;
+ }
+ .layout-twocol {
+ max-width: 910px;
+ }
+ .layout-rightnav {
+ flex: 0 0 290px;
+ }
+ .livemode-progress-bars {
+ max-width: 560px;
+ margin: 0 auto;
+ .progress {
+ height: 5px;
+ border-radius: 0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/pages/inbox.less b/ui/less/pages/inbox.less
new file mode 100644
index 0000000..af8e664
--- /dev/null
+++ b/ui/less/pages/inbox.less
@@ -0,0 +1,9 @@
+.inbox-page {
+ .layout-onecol {
+ .layout-main {
+ .message-feed {
+ max-width: 100%;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/less/pages/message.less b/ui/less/pages/message.less
new file mode 100644
index 0000000..0a5f651
--- /dev/null
+++ b/ui/less/pages/message.less
@@ -0,0 +1,10 @@
+.message-page {
+ padding: 24px 0 300px;
+ .message {
+ max-width: 560px;
+ }
+
+ .layout-rightnav > div {
+ margin: 24px 0;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/pages/profile.less b/ui/less/pages/profile.less
new file mode 100644
index 0000000..3a57840
--- /dev/null
+++ b/ui/less/pages/profile.less
@@ -0,0 +1,133 @@
+.profile-page {
+ .flagged-nsfw, .flagged-spam, .flagged-abuse {
+ display: none;
+ }
+ .show-nsfw .flagged-nsfw {
+ display: block;
+ }
+ .show-spam .flagged-spam {
+ display: block;
+ }
+ .show-abuse .flagged-abuse {
+ display: block;
+ }
+}
+
+.profile-header {
+ max-width: 560px;
+ margin: 8px auto 0;
+
+ h1 {
+ font-size: 56px;
+ color: #555;
+ display: inline;
+ }
+ .btn {
+ float: right;
+ margin-top: 22px;
+ }
+}
+
+.profile-controls {
+ width: 305px;
+
+ h2, p {
+ margin: 0;
+ }
+ .section {
+ margin-bottom: 24px;
+ }
+ li {
+ display: inline-block;
+ margin-right: 1em;
+ }
+
+ .follows-you {
+ text-align: center;
+ margin: 0 14px;
+ font-size: 18px;
+ background-color: #ddd;
+ padding: 5px 0;
+ color: #555;
+ }
+
+ .block {
+ background: #ddd;
+ padding: 2px 5px;
+ border-radius: 2px;
+ color: gray;
+ font-size: 18px;
+ text-align: center;
+ width: 275px;
+ margin: 0 0 0 15px;
+ }
+ .btns {
+ width: 275px;
+ margin: 0 0 20px 15px;
+
+ .btn-block {
+ font-size: 16px;
+ padding: 10px 0 8px;
+ .glyphicon {
+ margin-right: 8px;
+ }
+ }
+ .btns-group {
+ margin-top: 5px;
+ display: flex;
+ .btn {
+ flex: 1;
+ margin-right: 2px;
+ &:last-child {
+ margin: 0;
+ }
+ }
+ }
+ }
+
+ .relations {
+ margin: 0 0 25px 20px;
+ .user-hexagrid-60 {
+ margin: 15px 0 0;
+ }
+ .user-hexagon {
+ opacity: 1;
+ }
+ }
+ .connection-graph, .network-graph {
+ width: 275px;
+ height: 50px;
+ margin-left: 10px;
+ }
+}
+
+.profile-pics {
+ padding: 5px 0;
+ .pic {
+ position: relative;
+ display: inline-block;
+ margin: 1em 1em 1em 0;
+ img {
+ width: 275px;
+ height: 275px;
+ border-radius: 2px;
+ }
+ &:hover::before {
+ content: attr(data-overlay);
+ position: absolute;
+ bottom: 30px;
+ left: 0;
+ background: rgba(0,0,0,0.75);
+ font-weight: 100;
+ padding: 1em 2em;
+ color: #fff;
+ border-radius: 2px;
+ }
+ }
+}
+
+.profile-flags {
+ .message, .message .message-comments {
+ border-color: rgb(228, 104, 104);
+ }
+}
\ No newline at end of file
diff --git a/ui/less/pages/sync.less b/ui/less/pages/sync.less
new file mode 100644
index 0000000..bb30b53
--- /dev/null
+++ b/ui/less/pages/sync.less
@@ -0,0 +1,7 @@
+.sync-page {
+ .pub-status {
+ max-width: 840px;
+ margin: 0 auto 15px;
+ text-align: center;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/pages/webview.less b/ui/less/pages/webview.less
new file mode 100644
index 0000000..c5606d5
--- /dev/null
+++ b/ui/less/pages/webview.less
@@ -0,0 +1,10 @@
+.webview-page {
+ .webview-left {
+ border: 1px solid #ccc;
+ }
+ .webview-right {
+ flex: 0 0 305px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/tray-menu.less b/ui/less/tray-menu.less
new file mode 100644
index 0000000..21a3655
--- /dev/null
+++ b/ui/less/tray-menu.less
@@ -0,0 +1,139 @@
+// Core variables and mixins
+@import "variables.less";
+@import "bootstrap/mixins.less";
+@import "bootstrap/normalize.less";
+@import "bootstrap/glyphicons.less";
+@import "bootstrap/scaffolding.less";
+@import "bootstrap/type.less";
+
+// Phoenix styles
+@import "com/message-oneline.less";
+@import "com/message-summary.less";
+@import "com/message-feed.less";
+@import "com/pm-form.less";
+@import "com/suggest-box.less";
+
+// Tray-menu styles
+body {
+ background: #eee;
+}
+
+#nav {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 100;
+ width: 100%;
+ height: 48px;
+
+ display: flex;
+ border-bottom: 1px solid #aaa;
+ padding: 4px;
+ background: #fff;
+ font-weight: 100;
+
+ .spacer {
+ flex: 1;
+ }
+
+ a {
+ flex: 1;
+ height: 43px;
+ padding: 5px 8px;
+ text-align: center;
+ color: gray;
+ margin-right: 15px;
+ font-size: 21px;
+ text-decoration: none;
+
+ &:last-child {
+ margin-right: 0;
+ }
+
+ &:hover {
+ text-decoration: none;
+ color: #666;
+ }
+
+ &.selected {
+ border-bottom: 5px solid @brand-primary;
+ color: #555;
+ }
+
+ &.count {
+ position: relative;
+
+ &:after {
+ content: attr(data-count);
+ }
+
+ .glyphicon {
+ margin-right: 7px;
+ }
+ .glyphicon-user, .glyphicon-envelope {
+ font-size: 19px;
+ }
+
+ .unread {
+ position: absolute;
+ top: -4px;
+ right: 2px;
+ color: #fff;
+ background: #FF0000;
+ font-size: 9px;
+ border-radius: 3px;
+ padding: 0px 3px;
+ font-weight: normal;
+ }
+
+ &:hover {
+ text-decoration: none;
+ .unread {
+ background: lighten(#FF0000, 10%);
+ }
+ }
+ }
+
+ &.action {
+ background: @brand-primary;
+ color: #FFF;
+ border-radius: 2px;
+ height: 40px;
+ padding: 6px 10px;
+ &:hover {
+ background: linear-gradient(to bottom, @brand-primary, darken(@brand-primary, 10%));
+ }
+ &.selected {
+ border-color: #555;
+ }
+ }
+ }
+}
+
+#content {
+ margin-top: 48px;
+}
+
+.user-link {
+ font-weight: bold;
+ color: #555;
+}
+
+.message-oneline:first-child {
+ border-top: 0;
+}
+
+.message-oneline .message-oneline-column:nth-child(2) {
+ display: none;
+}
+
+.message-summary {
+ margin: 5px;
+ .message-summary-content {
+ background: #fff;
+ padding-right: 5px;
+ }
+ .msg-link {
+ padding-bottom: 0;
+ }
+}
\ No newline at end of file
diff --git a/ui/less/variables.less b/ui/less/variables.less
new file mode 100644
index 0000000..dba1849
--- /dev/null
+++ b/ui/less/variables.less
@@ -0,0 +1,870 @@
+//
+// Variables
+// --------------------------------------------------
+
+
+//== Colors
+//
+//## Gray and brand colors for use across Bootstrap.
+
+@gray-base: #000;
+@gray-darker: lighten(@gray-base, 13.5%); // #222
+@gray-dark: lighten(@gray-base, 20%); // #333
+@gray: lighten(@gray-base, 33.5%); // #555
+@gray-light: lighten(@gray-base, 46.7%); // #777
+@gray-lighter: lighten(@gray-base, 93.5%); // #eee
+
+@black-primary: #333;
+@black-secondary: #000;
+@purple-primary: #B611DA;
+@purple-secondary: rgb(137, 30, 160);
+@blue-primary: #006EFF;
+@blue-secondary: #0F305C;
+@blue-tertiary: #B9CFED;
+@green-primary: #1B9817;
+@green-secondary: rgb(19, 115, 16);
+@red-primary: rgb(208, 8, 8);
+@red-secondary: rgb(121, 18, 18);
+@yellow-primary: #f0ad4e;
+@yellow-secondary: #9B4112;
+
+@brand-primary: #2778E2;
+@brand-success: #12b812;
+@brand-info: @purple-primary;
+@brand-warning: #f0ad4e;
+@brand-danger: #d9534f;
+@brand-action: #555;
+
+//== Scaffolding
+//
+//## Settings for some of the most global styles.
+
+//** Background color for ``.
+@body-bg: #fff;
+//** Global text color on ``.
+@text-color: @gray-dark;
+
+//** Global textual link color.
+@link-color: @brand-primary;
+//** Link hover color set via `darken()` function.
+@link-hover-color: darken(@link-color, 15%);
+//** Link hover decoration.
+@link-hover-decoration: underline;
+
+
+//== Typography
+//
+//## Font, line-height, and color for body text, headings, and more.
+
+@font-family-sans-serif: Helvetica, Arial, sans-serif; //Verdana, Geneva, sans-serif;
+@font-family-serif: Georgia, "Times New Roman", Times, serif;
+//** Default monospace fonts for ``, ``, and ``.
+@font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
+@font-family-base: @font-family-sans-serif;
+
+@font-size-base: 14px;
+@font-size-large: ceil((@font-size-base * 1.25)); // ~18px
+@font-size-small: ceil((@font-size-base * 0.85)); // ~12px
+
+@font-size-h1: floor((@font-size-base * 2.6)); // ~36px
+@font-size-h2: floor((@font-size-base * 2.15)); // ~30px
+@font-size-h3: ceil((@font-size-base * 1.7)); // ~24px
+@font-size-h4: ceil((@font-size-base * 1.25)); // ~18px
+@font-size-h5: @font-size-base;
+@font-size-h6: ceil((@font-size-base * 0.85)); // ~12px
+
+//** Unit-less `line-height` for use in components like buttons.
+@line-height-base: 1.428571429; // 20/14
+//** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
+@line-height-computed: floor((@font-size-base * @line-height-base)); // ~20px
+
+//** By default, this inherits from the ``.
+@headings-font-family: inherit;
+@headings-font-weight: 500;
+@headings-line-height: 1.1;
+@headings-color: inherit;
+
+
+//== Iconography
+//
+//## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower.
+
+//** Load fonts from this directory.
+@icon-font-path: "../fonts/";
+//** File name for all font files.
+@icon-font-name: "glyphicons-halflings-regular";
+//** Element ID within SVG icon file.
+@icon-font-svg-id: "glyphicons_halflingsregular";
+
+
+//== Components
+//
+//## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
+
+@padding-base-vertical: 6px;
+@padding-base-horizontal: 12px;
+
+@padding-large-vertical: 10px;
+@padding-large-horizontal: 16px;
+
+@padding-small-vertical: 5px;
+@padding-small-horizontal: 10px;
+
+@padding-xs-vertical: 1px;
+@padding-xs-horizontal: 5px;
+
+@line-height-large: 1.33;
+@line-height-small: 1.5;
+
+@border-radius-base: 2px;
+@border-radius-large: 6px;
+@border-radius-small: 3px;
+
+//** Global color for active items (e.g., navs or dropdowns).
+@component-active-color: #fff;
+//** Global background color for active items (e.g., navs or dropdowns).
+@component-active-bg: @brand-primary;
+
+//** Width of the `border` for generating carets that indicator dropdowns.
+@caret-width-base: 4px;
+//** Carets increase slightly in size for larger components.
+@caret-width-large: 5px;
+
+
+//== Tables
+//
+//## Customizes the `.table` component with basic values, each used across all table variations.
+
+//** Padding for ``s and ` | `s.
+@table-cell-padding: 8px;
+//** Padding for cells in `.table-condensed`.
+@table-condensed-cell-padding: 5px;
+
+//** Default background color used for all tables.
+@table-bg: transparent;
+//** Background color used for `.table-striped`.
+@table-bg-accent: #f9f9f9;
+//** Background color used for `.table-hover`.
+@table-bg-hover: #f5f5f5;
+@table-bg-active: @table-bg-hover;
+
+//** Border color for table and cell borders.
+@table-border-color: #ddd;
+
+
+//== Buttons
+//
+//## For each of Bootstrap's buttons, define text, background and border color.
+
+@btn-font-weight: normal;
+
+@btn-default-color: #333;
+@btn-default-bg: #fff;
+@btn-default-border: #fff; //#ccc;
+
+@btn-primary-color: #fff;
+@btn-primary-bg: @brand-primary;
+@btn-primary-border: @brand-primary; //darken(@btn-primary-bg, 5%);
+
+@btn-success-color: @brand-success; //#fff;
+@btn-success-bg: #fff; //@brand-success;
+@btn-success-border: #fff; //darken(@btn-success-bg, 5%);
+
+@btn-info-color: #fff;
+@btn-info-bg: @brand-info;
+@btn-info-border: darken(@btn-info-bg, 5%);
+
+@btn-warning-color: @brand-warning; //#fff;
+@btn-warning-bg: #fff; //@brand-warning;
+@btn-warning-border: #fff; //darken(@btn-warning-bg, 5%);
+
+@btn-danger-color: @brand-danger; //#fff;
+@btn-danger-bg: #fff; //@brand-danger;
+@btn-danger-border: #fff; //darken(@btn-danger-bg, 5%);
+
+@btn-link-disabled-color: @gray-light;
+
+
+//== Forms
+//
+//##
+
+//** `` background color
+@input-bg: #fff;
+//** `` background color
+@input-bg-disabled: @gray-lighter;
+
+//** Text color for ``s
+@input-color: @gray;
+//** `` border color
+@input-border: #ccc;
+
+// TODO: Rename `@input-border-radius` to `@input-border-radius-base` in v4
+//** Default `.form-control` border radius
+@input-border-radius: @border-radius-base;
+//** Large `.form-control` border radius
+@input-border-radius-large: @border-radius-large;
+//** Small `.form-control` border radius
+@input-border-radius-small: @border-radius-small;
+
+//** Border color for inputs on focus
+@input-border-focus: #66afe9;
+
+//** Placeholder text color
+@input-color-placeholder: #999;
+
+//** Default `.form-control` height
+@input-height-base: (@line-height-computed + (@padding-base-vertical * 2) + 2);
+//** Large `.form-control` height
+@input-height-large: (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2);
+//** Small `.form-control` height
+@input-height-small: (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2);
+
+@legend-color: @gray-dark;
+@legend-border-color: #e5e5e5;
+
+//** Background color for textual input addons
+@input-group-addon-bg: @gray-lighter;
+//** Border color for textual input addons
+@input-group-addon-border-color: @input-border;
+
+//** Disabled cursor for form controls and buttons.
+@cursor-disabled: not-allowed;
+
+
+//== Dropdowns
+//
+//## Dropdown menu container and contents.
+
+//** Background for the dropdown menu.
+@dropdown-bg: #fff;
+//** Dropdown menu `border-color`.
+@dropdown-border: rgba(0,0,0,.15);
+//** Dropdown menu `border-color` **for IE8**.
+@dropdown-fallback-border: #ccc;
+//** Divider color for between dropdown items.
+@dropdown-divider-bg: #e5e5e5;
+
+//** Dropdown link text color.
+@dropdown-link-color: @gray-dark;
+//** Hover color for dropdown links.
+@dropdown-link-hover-color: darken(@gray-dark, 5%);
+//** Hover background for dropdown links.
+@dropdown-link-hover-bg: #f5f5f5;
+
+//** Active dropdown menu item text color.
+@dropdown-link-active-color: @component-active-color;
+//** Active dropdown menu item background color.
+@dropdown-link-active-bg: @component-active-bg;
+
+//** Disabled dropdown menu item background color.
+@dropdown-link-disabled-color: @gray-light;
+
+//** Text color for headers within dropdown menus.
+@dropdown-header-color: @gray-light;
+
+//** Deprecated `@dropdown-caret-color` as of v3.1.0
+@dropdown-caret-color: #000;
+
+
+//-- Z-index master list
+//
+// Warning: Avoid customizing these values. They're used for a bird's eye view
+// of components dependent on the z-axis and are designed to all work together.
+//
+// Note: These variables are not generated into the Customizer.
+
+@zindex-navbar: 1000;
+@zindex-dropdown: 1000;
+@zindex-popover: 1060;
+@zindex-tooltip: 1070;
+@zindex-navbar-fixed: 1030;
+@zindex-modal: 1040;
+
+
+//== Media queries breakpoints
+//
+//## Define the breakpoints at which your layout will change, adapting to different screen sizes.
+
+// Extra small screen / phone
+//** Deprecated `@screen-xs` as of v3.0.1
+@screen-xs: 480px;
+//** Deprecated `@screen-xs-min` as of v3.2.0
+@screen-xs-min: @screen-xs;
+//** Deprecated `@screen-phone` as of v3.0.1
+@screen-phone: @screen-xs-min;
+
+// Small screen / tablet
+//** Deprecated `@screen-sm` as of v3.0.1
+@screen-sm: 768px;
+@screen-sm-min: @screen-sm;
+//** Deprecated `@screen-tablet` as of v3.0.1
+@screen-tablet: @screen-sm-min;
+
+// Medium screen / desktop
+//** Deprecated `@screen-md` as of v3.0.1
+@screen-md: 992px;
+@screen-md-min: @screen-md;
+//** Deprecated `@screen-desktop` as of v3.0.1
+@screen-desktop: @screen-md-min;
+
+// Large screen / wide desktop
+//** Deprecated `@screen-lg` as of v3.0.1
+@screen-lg: 1200px;
+@screen-lg-min: @screen-lg;
+//** Deprecated `@screen-lg-desktop` as of v3.0.1
+@screen-lg-desktop: @screen-lg-min;
+
+// So media queries don't overlap when required, provide a maximum
+@screen-xs-max: (@screen-sm-min - 1);
+@screen-sm-max: (@screen-md-min - 1);
+@screen-md-max: (@screen-lg-min - 1);
+
+
+//== Grid system
+//
+//## Define your custom responsive grid.
+
+//** Number of columns in the grid.
+@grid-columns: 12;
+//** Padding between columns. Gets divided in half for the left and right.
+@grid-gutter-width: 0;
+// Navbar collapse
+//** Point at which the navbar becomes uncollapsed.
+@grid-float-breakpoint: 1px;//@screen-sm-min;
+//** Point at which the navbar begins collapsing.
+@grid-float-breakpoint-max: (@grid-float-breakpoint - 1);
+
+
+//== Container sizes
+//
+//## Define the maximum width of `.container` for different screen sizes.
+
+// Small screen / tablet
+@container-tablet: auto; //(720px + @grid-gutter-width);
+//** For `@screen-sm-min` and up.
+@container-sm: @container-tablet;
+
+// Medium screen / desktop
+@container-desktop: auto; //(940px + @grid-gutter-width);
+//** For `@screen-md-min` and up.
+@container-md: @container-desktop;
+
+// Large screen / wide desktop
+@container-large-desktop: (1140px + @grid-gutter-width);
+//** For `@screen-lg-min` and up.
+@container-lg: @container-large-desktop;
+
+
+//== Navbar
+//
+//##
+
+// Basics of a navbar
+@navbar-height: 50px;
+@navbar-margin-bottom: 5px;//@line-height-computed;
+@navbar-border-radius: @border-radius-base;
+@navbar-padding-horizontal: floor((@grid-gutter-width / 2));
+@navbar-padding-vertical: ((@navbar-height - @line-height-computed) / 2);
+@navbar-collapse-max-height: 340px;
+
+@navbar-default-color: #777;
+@navbar-default-bg: #fff;
+@navbar-default-border: #fff; //darken(@navbar-default-bg, 6.5%);
+
+// Navbar links
+@navbar-default-link-color: #777;
+@navbar-default-link-hover-color: #333;
+@navbar-default-link-hover-bg: transparent;
+@navbar-default-link-active-color: #555;
+@navbar-default-link-active-bg: darken(@navbar-default-bg, 6.5%);
+@navbar-default-link-disabled-color: #ccc;
+@navbar-default-link-disabled-bg: transparent;
+
+// Navbar brand label
+@navbar-default-brand-color: @navbar-default-link-color;
+@navbar-default-brand-hover-color: darken(@navbar-default-brand-color, 10%);
+@navbar-default-brand-hover-bg: transparent;
+
+// Navbar toggle
+@navbar-default-toggle-hover-bg: #ddd;
+@navbar-default-toggle-icon-bar-bg: #888;
+@navbar-default-toggle-border-color: #ddd;
+
+
+// Inverted navbar
+// Reset inverted navbar basics
+@navbar-inverse-color: lighten(@gray-light, 15%);
+@navbar-inverse-bg: #222;
+@navbar-inverse-border: darken(@navbar-inverse-bg, 10%);
+
+// Inverted navbar links
+@navbar-inverse-link-color: lighten(@gray-light, 15%);
+@navbar-inverse-link-hover-color: #fff;
+@navbar-inverse-link-hover-bg: transparent;
+@navbar-inverse-link-active-color: @navbar-inverse-link-hover-color;
+@navbar-inverse-link-active-bg: darken(@navbar-inverse-bg, 10%);
+@navbar-inverse-link-disabled-color: #444;
+@navbar-inverse-link-disabled-bg: transparent;
+
+// Inverted navbar brand label
+@navbar-inverse-brand-color: @navbar-inverse-link-color;
+@navbar-inverse-brand-hover-color: #fff;
+@navbar-inverse-brand-hover-bg: transparent;
+
+// Inverted navbar toggle
+@navbar-inverse-toggle-hover-bg: #333;
+@navbar-inverse-toggle-icon-bar-bg: #fff;
+@navbar-inverse-toggle-border-color: #333;
+
+
+//== Navs
+//
+//##
+
+//=== Shared nav styles
+@nav-link-padding: 10px 15px;
+@nav-link-hover-bg: @gray-lighter;
+
+@nav-disabled-link-color: @gray-light;
+@nav-disabled-link-hover-color: @gray-light;
+
+//== Tabs
+@nav-tabs-border-color: #ddd;
+
+@nav-tabs-link-hover-border-color: @gray-lighter;
+
+@nav-tabs-active-link-hover-bg: @body-bg;
+@nav-tabs-active-link-hover-color: @gray;
+@nav-tabs-active-link-hover-border-color: #ddd;
+
+@nav-tabs-justified-link-border-color: #ddd;
+@nav-tabs-justified-active-link-border-color: @body-bg;
+
+//== Pills
+@nav-pills-border-radius: @border-radius-base;
+@nav-pills-active-link-hover-bg: @component-active-bg;
+@nav-pills-active-link-hover-color: @component-active-color;
+
+
+//== Pagination
+//
+//##
+
+@pagination-color: @link-color;
+@pagination-bg: #fff;
+@pagination-border: #ddd;
+
+@pagination-hover-color: @link-hover-color;
+@pagination-hover-bg: @gray-lighter;
+@pagination-hover-border: #ddd;
+
+@pagination-active-color: #fff;
+@pagination-active-bg: @brand-primary;
+@pagination-active-border: @brand-primary;
+
+@pagination-disabled-color: @gray-light;
+@pagination-disabled-bg: #fff;
+@pagination-disabled-border: #ddd;
+
+
+//== Pager
+//
+//##
+
+@pager-bg: @pagination-bg;
+@pager-border: @pagination-border;
+@pager-border-radius: 15px;
+
+@pager-hover-bg: @pagination-hover-bg;
+
+@pager-active-bg: @pagination-active-bg;
+@pager-active-color: @pagination-active-color;
+
+@pager-disabled-color: @pagination-disabled-color;
+
+
+//== Jumbotron
+//
+//##
+
+@jumbotron-padding: 30px;
+@jumbotron-color: inherit;
+@jumbotron-bg: @gray-lighter;
+@jumbotron-heading-color: inherit;
+@jumbotron-font-size: ceil((@font-size-base * 1.5));
+
+
+//== Form states and alerts
+//
+//## Define colors for form feedback states and, by default, alerts.
+
+@state-success-text: #3c763d;
+@state-success-bg: #dff0d8;
+@state-success-border: darken(spin(@state-success-bg, -10), 5%);
+
+@state-info-text: #31708f;
+@state-info-bg: #d9edf7;
+@state-info-border: darken(spin(@state-info-bg, -10), 7%);
+
+@state-warning-text: #8a6d3b;
+@state-warning-bg: #fcf8e3;
+@state-warning-border: darken(spin(@state-warning-bg, -10), 5%);
+
+@state-danger-text: #a94442;
+@state-danger-bg: #f2dede;
+@state-danger-border: darken(spin(@state-danger-bg, -10), 5%);
+
+
+//== Tooltips
+//
+//##
+
+//** Tooltip max width
+@tooltip-max-width: 200px;
+//** Tooltip text color
+@tooltip-color: #fff;
+//** Tooltip background color
+@tooltip-bg: #000;
+@tooltip-opacity: .9;
+
+//** Tooltip arrow width
+@tooltip-arrow-width: 5px;
+//** Tooltip arrow color
+@tooltip-arrow-color: @tooltip-bg;
+
+
+//== Popovers
+//
+//##
+
+//** Popover body background color
+@popover-bg: #fff;
+//** Popover maximum width
+@popover-max-width: 276px;
+//** Popover border color
+@popover-border-color: rgba(0,0,0,.2);
+//** Popover fallback border color
+@popover-fallback-border-color: #ccc;
+
+//** Popover title background color
+@popover-title-bg: darken(@popover-bg, 3%);
+
+//** Popover arrow width
+@popover-arrow-width: 10px;
+//** Popover arrow color
+@popover-arrow-color: @popover-bg;
+
+//** Popover outer arrow width
+@popover-arrow-outer-width: (@popover-arrow-width + 1);
+//** Popover outer arrow color
+@popover-arrow-outer-color: fadein(@popover-border-color, 5%);
+//** Popover outer arrow fallback color
+@popover-arrow-outer-fallback-color: darken(@popover-fallback-border-color, 20%);
+
+
+//== Labels
+//
+//##
+
+//** Default label background color
+@label-default-bg: @gray-light;
+//** Primary label background color
+@label-primary-bg: @brand-primary;
+//** Success label background color
+@label-success-bg: @brand-success;
+//** Info label background color
+@label-info-bg: @brand-info;
+//** Warning label background color
+@label-warning-bg: @brand-warning;
+//** Danger label background color
+@label-danger-bg: @brand-danger;
+
+//** Default label text color
+@label-color: #fff;
+//** Default text color of a linked label
+@label-link-hover-color: #fff;
+
+
+//== Modals
+//
+//##
+
+//** Padding applied to the modal body
+@modal-inner-padding: 15px;
+
+//** Padding applied to the modal title
+@modal-title-padding: 15px;
+//** Modal title line-height
+@modal-title-line-height: @line-height-base;
+
+//** Background color of modal content area
+@modal-content-bg: #fff;
+//** Modal content border color
+@modal-content-border-color: rgba(0,0,0,.2);
+//** Modal content border color **for IE8**
+@modal-content-fallback-border-color: #999;
+
+//** Modal backdrop background color
+@modal-backdrop-bg: #000;
+//** Modal backdrop opacity
+@modal-backdrop-opacity: .5;
+//** Modal header border color
+@modal-header-border-color: #e5e5e5;
+//** Modal footer border color
+@modal-footer-border-color: @modal-header-border-color;
+
+@modal-lg: 900px;
+@modal-md: 600px;
+@modal-sm: 300px;
+
+
+//== Alerts
+//
+//## Define alert colors, border radius, and padding.
+
+@alert-padding: 15px;
+@alert-border-radius: @border-radius-base;
+@alert-link-font-weight: bold;
+
+@alert-success-bg: @state-success-bg;
+@alert-success-text: @state-success-text;
+@alert-success-border: @state-success-border;
+
+@alert-info-bg: @state-info-bg;
+@alert-info-text: @state-info-text;
+@alert-info-border: @state-info-border;
+
+@alert-warning-bg: @state-warning-bg;
+@alert-warning-text: @state-warning-text;
+@alert-warning-border: @state-warning-border;
+
+@alert-danger-bg: @state-danger-bg;
+@alert-danger-text: @state-danger-text;
+@alert-danger-border: @state-danger-border;
+
+
+//== Progress bars
+//
+//##
+
+//** Background color of the whole progress component
+@progress-bg: #f5f5f5;
+//** Progress bar text color
+@progress-bar-color: #fff;
+//** Variable for setting rounded corners on progress bar.
+@progress-border-radius: @border-radius-base;
+
+//** Default progress bar color
+@progress-bar-bg: @brand-primary;
+//** Success progress bar color
+@progress-bar-success-bg: @brand-success;
+//** Warning progress bar color
+@progress-bar-warning-bg: @brand-warning;
+//** Danger progress bar color
+@progress-bar-danger-bg: @brand-danger;
+//** Info progress bar color
+@progress-bar-info-bg: @brand-info;
+
+
+//== List group
+//
+//##
+
+//** Background color on `.list-group-item`
+@list-group-bg: #fff;
+//** `.list-group-item` border color
+@list-group-border: #ddd;
+//** List group border radius
+@list-group-border-radius: @border-radius-base;
+
+//** Background color of single list items on hover
+@list-group-hover-bg: #f5f5f5;
+//** Text color of active list items
+@list-group-active-color: @component-active-color;
+//** Background color of active list items
+@list-group-active-bg: @component-active-bg;
+//** Border color of active list elements
+@list-group-active-border: @list-group-active-bg;
+//** Text color for content within active list items
+@list-group-active-text-color: lighten(@list-group-active-bg, 40%);
+
+//** Text color of disabled list items
+@list-group-disabled-color: @gray-light;
+//** Background color of disabled list items
+@list-group-disabled-bg: @gray-lighter;
+//** Text color for content within disabled list items
+@list-group-disabled-text-color: @list-group-disabled-color;
+
+@list-group-link-color: #555;
+@list-group-link-hover-color: @list-group-link-color;
+@list-group-link-heading-color: #333;
+
+
+//== Panels
+//
+//##
+
+@panel-bg: #fff;
+@panel-body-padding: 15px;
+@panel-heading-padding: 10px 15px;
+@panel-footer-padding: @panel-heading-padding;
+@panel-border-radius: @border-radius-base;
+
+//** Border color for elements within panels
+@panel-inner-border: #ddd;
+@panel-footer-bg: #f5f5f5;
+
+@panel-default-text: @gray-dark;
+@panel-default-border: #ddd;
+@panel-default-heading-bg: #f5f5f5;
+
+@panel-primary-text: #fff;
+@panel-primary-border: @brand-primary;
+@panel-primary-heading-bg: @brand-primary;
+
+@panel-success-text: @state-success-text;
+@panel-success-border: @state-success-border;
+@panel-success-heading-bg: @state-success-bg;
+
+@panel-info-text: @state-info-text;
+@panel-info-border: @state-info-border;
+@panel-info-heading-bg: @state-info-bg;
+
+@panel-warning-text: @state-warning-text;
+@panel-warning-border: @state-warning-border;
+@panel-warning-heading-bg: @state-warning-bg;
+
+@panel-danger-text: @state-danger-text;
+@panel-danger-border: @state-danger-border;
+@panel-danger-heading-bg: @state-danger-bg;
+
+
+//== Thumbnails
+//
+//##
+
+//** Padding around the thumbnail image
+@thumbnail-padding: 4px;
+//** Thumbnail background color
+@thumbnail-bg: @body-bg;
+//** Thumbnail border color
+@thumbnail-border: #ddd;
+//** Thumbnail border radius
+@thumbnail-border-radius: @border-radius-base;
+
+//** Custom text color for thumbnail captions
+@thumbnail-caption-color: @text-color;
+//** Padding around the thumbnail caption
+@thumbnail-caption-padding: 9px;
+
+
+//== Wells
+//
+//##
+
+@well-bg: #f5f5f5;
+@well-border: darken(@well-bg, 7%);
+
+
+//== Badges
+//
+//##
+
+@badge-color: #fff;
+//** Linked badge text color on hover
+@badge-link-hover-color: #fff;
+@badge-bg: @gray-light;
+
+//** Badge text color in active nav link
+@badge-active-color: @link-color;
+//** Badge background color in active nav link
+@badge-active-bg: #fff;
+
+@badge-font-weight: bold;
+@badge-line-height: 1;
+@badge-border-radius: 10px;
+
+
+//== Breadcrumbs
+//
+//##
+
+@breadcrumb-padding-vertical: 8px;
+@breadcrumb-padding-horizontal: 15px;
+//** Breadcrumb background color
+@breadcrumb-bg: #f5f5f5;
+//** Breadcrumb text color
+@breadcrumb-color: #ccc;
+//** Text color of current page in the breadcrumb
+@breadcrumb-active-color: @gray-light;
+//** Textual separator for between breadcrumb elements
+@breadcrumb-separator: "/";
+
+
+//== Carousel
+//
+//##
+
+@carousel-text-shadow: 0 1px 2px rgba(0,0,0,.6);
+
+@carousel-control-color: #fff;
+@carousel-control-width: 15%;
+@carousel-control-opacity: .5;
+@carousel-control-font-size: 20px;
+
+@carousel-indicator-active-bg: #fff;
+@carousel-indicator-border-color: #fff;
+
+@carousel-caption-color: #fff;
+
+
+//== Close
+//
+//##
+
+@close-font-weight: bold;
+@close-color: #000;
+@close-text-shadow: 0 1px 0 #fff;
+
+
+//== Code
+//
+//##
+
+@code-color: #333333;
+@code-bg: #f5f5f5;
+
+@kbd-color: #fff;
+@kbd-bg: #333;
+
+@pre-bg: #f5f5f5;
+@pre-color: @gray-dark;
+@pre-border-color: #ccc;
+@pre-scrollable-max-height: 340px;
+
+
+//== Type
+//
+//##
+
+//** Horizontal offset for forms and lists.
+@component-offset-horizontal: 180px;
+//** Text muted color
+@text-muted: @gray-light;
+//** Abbreviations and acronyms border color
+@abbr-border-color: @gray-light;
+//** Headings small color
+@headings-small-color: @gray-light;
+//** Blockquote small color
+@blockquote-small-color: @gray-light;
+//** Blockquote font size
+@blockquote-font-size: @font-size-base;
+//** Blockquote border color
+@blockquote-border-color: @gray-lighter;
+//** Page header border color
+@page-header-border-color: @gray-lighter;
+//** Width of horizontal description list titles
+@dl-horizontal-offset: @component-offset-horizontal;
+//** Horizontal line color.
+@hr-border: @gray-lighter;
diff --git a/ui/lib/app.js b/ui/lib/app.js
new file mode 100644
index 0000000..8502865
--- /dev/null
+++ b/ui/lib/app.js
@@ -0,0 +1,135 @@
+'use strict'
+
+/*
+Application Master State
+========================
+Common state which either exists as part of the session,
+or which has been loaded from scuttlebot during page
+refresh because its commonly needed during rendering.
+*/
+
+var o = require('observable')
+var multicb = require('multicb')
+var SSBClient = require('./muxrpc-ipc')
+var emojis = require('emoji-named-characters')
+
+// master state object
+var app =
+module.exports = {
+ // sbot rpc connection
+ ssb: SSBClient(),
+
+ // pull state from sbot, called on every pageload
+ fetchLatestState: fetchLatestState,
+
+ // page params parsed from the url
+ page: {
+ id: 'home',
+ param: null,
+ qs: {}
+ },
+
+ // ui data
+ suggestOptions: {
+ ':': Object.keys(emojis).map(function (emoji) {
+ return {
+ image: './img/emoji/' + emoji + '.png',
+ title: emoji,
+ subtitle: emoji,
+ value: emoji + ':'
+ }
+ }),
+ '@': []
+ },
+ homeMode: {
+ view: 'all',
+ live: true
+ },
+ filters: {
+ nsfw: true,
+ spam: true,
+ abuse: true
+ },
+
+ // application state, fetched every refresh
+ actionItems: {},
+ indexCounts: {},
+ user: {
+ id: null,
+ profile: {}
+ },
+ users: {
+ names: {},
+ profiles: {}
+ },
+ peers: [],
+
+ // global observables, updated by persistent events
+ observ: {
+ sideview: o(true),
+ peers: o([]),
+ hasSyncIssue: o(false),
+ newPosts: o(0),
+ indexCounts: {
+ inbox: o(0),
+ votes: o(0),
+ follows: o(0),
+ inboxUnread: o(0),
+ votesUnread: o(0),
+ followsUnread: o(0)
+ }
+ }
+}
+
+var firstFetch = true
+function fetchLatestState (cb) {
+ var done = multicb({ pluck: 1 })
+ app.ssb.whoami(done())
+ app.ssb.patchwork.getNamesById(done())
+ app.ssb.patchwork.getAllProfiles(done())
+ app.ssb.patchwork.getActionItems(done())
+ app.ssb.patchwork.getIndexCounts(done())
+ app.ssb.gossip.peers(done())
+ done(function (err, data) {
+ if (err) throw err.message
+ app.user.id = data[0].id
+ app.users.names = data[1]
+ app.users.profiles = data[2]
+ app.actionItems = data[3]
+ app.indexCounts = data[4]
+ app.peers = data[5]
+ app.user.profile = app.users.profiles[app.user.id]
+
+ // update observables
+ app.observ.peers(app.peers)
+ var stats = require('./util').getPubStats()
+ app.observ.hasSyncIssue(!stats.membersof || !stats.active)
+ for (var k in app.indexCounts)
+ if (app.observ.indexCounts[k])
+ app.observ.indexCounts[k](app.indexCounts[k])
+
+ // refresh suggest options for usernames
+ app.suggestOptions['@'] = []
+ for (var id in app.users.profiles) {
+ if (id == app.user.profile.id || (app.user.profile.assignedTo[id] && app.user.profile.assignedTo[id].following)) {
+ var name = app.users.names[id]
+ app.suggestOptions['@'].push({
+ id: id,
+ cls: 'user',
+ title: name || id,
+ image: require('./com').profilePicUrl(id),
+ subtitle: name || id,
+ value: name || id.slice(1) // if using id, dont include the @ sigil
+ })
+ }
+ }
+
+ // do some first-load things
+ if (firstFetch) {
+ app.observ.newPosts(0) // trigger title render, so we get the correct name
+ firstFetch = false
+ }
+
+ cb()
+ })
+}
\ No newline at end of file
diff --git a/ui/lib/com/composer.js b/ui/lib/com/composer.js
new file mode 100644
index 0000000..711d5f7
--- /dev/null
+++ b/ui/lib/com/composer.js
@@ -0,0 +1,43 @@
+'use strict'
+var h = require('hyperscript')
+var o = require('observable')
+var com = require('./index')
+
+module.exports = function (rootMsg, branchMsg, opts) {
+
+ var selection = o('post')
+ function navitem (icon, value) {
+ return o.transform(selection, function (s) {
+ return h('a'+((s == value) ? '.selected' : ''), { onclick: onSelect(value) }, com.icon(icon))
+ })
+ }
+
+ // markup
+
+ var header = h('.composer-header',
+ h('.composer-header-nav',
+ navitem('comment', 'post'),
+ navitem('facetime-video', 'webcam')
+ // navitem('picture', 'image')
+ ),
+ h('.composer-header-body', o.transform(selection, function (s) {
+ if (s == 'post')
+ return com.postForm(rootMsg, branchMsg, { onpost: opts.onpost, noheader: true })
+ if (s == 'webcam')
+ return com.webcamGifferForm(rootMsg, branchMsg, { onpost: opts.onpost })
+ if (s == 'image')
+ return com.imagesForm()
+ }))
+ )
+
+ // handlers
+
+ function onSelect (value) {
+ return function (e) {
+ e.preventDefault()
+ selection(value)
+ }
+ }
+
+ return header
+}
\ No newline at end of file
diff --git a/ui/lib/com/connection-graph.js b/ui/lib/com/connection-graph.js
new file mode 100644
index 0000000..7033c25
--- /dev/null
+++ b/ui/lib/com/connection-graph.js
@@ -0,0 +1,90 @@
+var h = require('hyperscript')
+var app = require('../app')
+var com = require('./index')
+
+module.exports = function (from, to, opts) {
+ var container = h('.connection-graph')
+ opts = opts || {}
+ opts.w = opts.w || 3
+ opts.h = opts.h || 1
+ app.ssb.friends.all(function (err, friends) {
+
+ // generate graph
+ var graph = { nodes: [], edges: [] }
+ for (var id in friends) {
+ // add node
+ var inbounds = countInbounds(friends, id)
+ if (id == from) {
+ graph.nodes.push({
+ id: id,
+ type: 'square',
+ label: com.userName(id),
+ x: 0.05 * opts.w,
+ y: 0.5 * opts.h,
+ size: inbounds+1,
+ color: '#970'
+ })
+ } else if (id == to) {
+ graph.nodes.push({
+ id: id,
+ type: 'square',
+ label: com.userName(id),
+ x: 0.95 * opts.w,
+ y: 0.5 * opts.h,
+ size: inbounds+1,
+ color: '#970'
+ })
+ } else if (onpath(friends, from, to, id)) {
+ var xr = Math.random() * 0.2
+ var yr = Math.random()
+ graph.nodes.push({
+ id: id,
+ type: 'square',
+ label: com.userName(id),
+ x: (0.4 + xr) * opts.w,
+ y: yr * opts.h,
+ size: inbounds+1,
+ color: '#790'
+ })
+ } else {
+ continue;
+ }
+
+ // show edges related to from/to
+ for (var id2 in friends[id]) {
+ if (id == from && onpath(friends, from, to, id2) || id2 == to && onpath(friends, from, to, id) || id == from && id2 == to) {
+ graph.edges.push({
+ id: id+'->'+id2,
+ source: id,
+ target: id2,
+ size: (id == from && id2 == to) ? 1 : 0.1,
+ color: (id == from && id2 == to) ? '#97a' : (id == from) ? '#c93' : '#9a3'
+ })
+ }
+ }
+ }
+
+ // render
+ var s = new sigma({
+ graph: graph,
+ renderer: { container: container, type: 'canvas' },
+ settings: opts
+ })
+ })
+ return container
+}
+
+function countInbounds (graph, id) {
+ var n=0
+ for (var id2 in graph) {
+ if (id in graph[id2])
+ n++
+ }
+ return n
+}
+
+function onpath (graph, from, to, id) {
+ if (graph[from] && graph[from][id] && graph[id] && graph[id][to])
+ return true
+ return false
+}
\ No newline at end of file
diff --git a/ui/lib/com/contact-feed.js b/ui/lib/com/contact-feed.js
new file mode 100644
index 0000000..0fc2757
--- /dev/null
+++ b/ui/lib/com/contact-feed.js
@@ -0,0 +1,29 @@
+'use strict'
+var h = require('hyperscript')
+var mlib = require('ssb-msgs')
+var pull = require('pull-stream')
+var multicb = require('multicb')
+var app = require('../app')
+var com = require('../com')
+
+var mustRenderOpts = { mustRender: true }
+module.exports = function (opts) {
+ opts = opts || {}
+
+ // markup
+
+ var items = []
+ for (var uid in app.users.profiles) {
+ if (!opts.filter || opts.filter(app.users.profiles[uid]))
+ items.push(com.contactListing(app.users.profiles[uid], opts))
+ }
+
+ items.sort(function (a, b) {
+ return b.dataset.followers - a.dataset.followers
+ })
+
+ var feedel = h('.contact-feed', items.slice(0, opts.limit || 30))
+ if (items.length === 0 && opts.onempty)
+ opts.onempty(feedel)
+ return h('.contact-feed-container', feedel)
+}
\ No newline at end of file
diff --git a/ui/lib/com/contact-listing.js b/ui/lib/com/contact-listing.js
new file mode 100644
index 0000000..eb83f6a
--- /dev/null
+++ b/ui/lib/com/contact-listing.js
@@ -0,0 +1,65 @@
+var h = require('hyperscript')
+var schemas = require('ssb-msg-schemas')
+var app = require('../app')
+var ui = require('../ui')
+var modals = require('../ui/modals')
+var com = require('./index')
+var u = require('../util')
+var social = require('../social-graph')
+
+module.exports = function (profile, opts) {
+
+ // markup
+
+ var id = profile.id
+ var isfollowed = social.follows(app.user.id, profile.id)
+ var nfollowers = social.followedFollowers(app.user.id, id).length
+
+ var followbtn
+ renderFollow()
+ function renderFollow () {
+ if (id != app.user.id) {
+ var newbtn
+ if (!isfollowed)
+ newbtn = h('button.btn.btn-3d', { title: 'Follow', onclick: toggleFollow }, com.icon('plus'), ' Follow')
+ else
+ newbtn = h('button.btn.btn-3d', { title: 'Unfollow', onclick: toggleFollow }, com.icon('minus'), ' Unfollow')
+ if (followbtn)
+ followbtn.parentNode.replaceChild(newbtn, followbtn)
+ followbtn = newbtn
+ }
+ }
+
+ // render
+ var listing = h('.contact-listing' + ((opts && opts.compact) ? '.compact' : ''),
+ h('.profpic', com.userHexagon(id, (opts && opts.compact) ? 45 : 80)),
+ h('.details',
+ h('p.name', com.a('#/profile/'+id, app.users.names[id] || id)),
+ h('p', com.userRelationship(id, nfollowers))
+ ),
+ (!opts || !opts.compact) ? h('.actions', followbtn) : ''
+ )
+ listing.dataset.followers = nfollowers
+ return listing
+
+ // handlers
+
+ function toggleFollow (e) {
+ e.preventDefault()
+
+ // optimistically render
+ isfollowed = !isfollowed
+ renderFollow()
+
+ // update
+ ui.pleaseWait(true, 1000)
+ app.ssb.publish((isfollowed) ? schemas.follow(profile.id) : schemas.unfollow(profile.id), function (err) {
+ ui.pleaseWait(false)
+ if (err) {
+ isfollowed = !isfollowed
+ renderFollow()
+ modals.error('Error While Publishing', err, 'This error occurred while trying to toggle follow on another user.')
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/ui/lib/com/contact-plaque.js b/ui/lib/com/contact-plaque.js
new file mode 100644
index 0000000..a8c1c82
--- /dev/null
+++ b/ui/lib/com/contact-plaque.js
@@ -0,0 +1,68 @@
+var h = require('hyperscript')
+var schemas = require('ssb-msg-schemas')
+var app = require('../app')
+var com = require('./index')
+var u = require('../util')
+
+module.exports = function (profile, nfollowers, nflaggers) {
+
+ // markup
+
+ var contactId = profile.id
+ var isSelf = (contactId == app.user.id)
+
+ var profileImg = com.profilePicUrl(contactId)
+ var totem = h('.totem',
+ h('span.corner.topleft'),
+ h('span.corner.topright'),
+ h('span.corner.botleft', { 'data-overlay': 'Followers' }, h('.corner-inner', nfollowers, com.icon('user'))),
+ h('span.corner.botright', { 'data-overlay': 'Flags' }, h('.corner-inner', nflaggers, com.icon('flag'))),
+ h('a.profpic', { href: '#/profile/'+contactId }, com.hexagon(profileImg, 275)))
+
+ // profile title
+ var title = h('.title', h('h2', com.userName(contactId)))
+
+ // totem colors derived from the image
+ var tmpImg = document.createElement('img')
+ tmpImg.src = profileImg
+ tmpImg.onload = function () {
+ var rgb = u.getAverageRGB(tmpImg)
+ if (rgb) {
+ // color-correct to try to go within 96-128 of average
+ var avg = (rgb.r + rgb.g + rgb.b) / 3
+ if (avg > 128) {
+ rgb.r = (rgb.r/2)|0
+ rgb.g = (rgb.g/2)|0
+ rgb.b = (rgb.b/2)|0
+ avg = (rgb.r + rgb.g + rgb.b) / 3
+ }
+ var n=0
+ while (avg < 96 && (n++ < 50)) {
+ var ratio = (96 - avg)/96 + 1
+ if (ratio < 1.2)
+ ratio = 1.2
+ rgb.r = (rgb.r*ratio)|0
+ rgb.g = (rgb.g*ratio)|0
+ rgb.b = (rgb.b*ratio)|0
+ avg = (rgb.r + rgb.g + rgb.b) / 3
+ }
+ var rgb2 = { r: ((rgb.r/2)|0), g: ((rgb.g/2)|0), b: ((rgb.b/2)|0) }
+
+ try { title.querySelector('h2').style.color = 'rgb('+rgb2.r+','+rgb2.g+','+rgb2.b+')' } catch (e) {}
+ try { title.querySelector('h3').style.color = 'rgba('+rgb2.r+','+rgb2.g+','+rgb2.b+', 0.75)' } catch (e) {}
+ try { title.querySelector('p').style.color = 'rgba('+rgb2.r+','+rgb2.g+','+rgb2.b+', 0.75)' } catch (e) {}
+ function setColors (el) {
+ if (!el.classList.contains('selected')) {
+ el.style.color = 'rgba(255,255,255,0.35)'//'rgb('+rgb.r+','+rgb.g+','+rgb.b+')'
+ el.style.background = 'rgb('+rgb2.r+','+rgb2.g+','+rgb2.b+')'
+ } else {
+ el.style.color = 'rgba(255,255,255,0.5)'
+ el.style.background = 'rgb('+rgb.r+','+rgb.g+','+rgb.b+')'
+ }
+ }
+ Array.prototype.forEach.call(totem.querySelectorAll('.corner'), setColors)
+ }
+ }
+
+ return h('.contact-summary', totem, title)
+}
\ No newline at end of file
diff --git a/ui/lib/com/files.js b/ui/lib/com/files.js
new file mode 100644
index 0000000..f9e125a
--- /dev/null
+++ b/ui/lib/com/files.js
@@ -0,0 +1,42 @@
+var h = require('hyperscript')
+var com = require('./index')
+var app = require('../app')
+var u = require('../util')
+
+module.exports = function (uid) {
+ var el = h('.files-items')
+ /*app.ssb.patchwork.*/getNamespace(uid, function (err, items) {
+ if (!items) return
+ items.forEach(function (item) {
+ el.appendChild(file(uid, item))
+ })
+ })
+ return h('.files',
+ h('.files-headers', h('div', 'Name'), h('div', 'File Size'), h('div', 'Modified')),
+ el
+ )
+}
+
+function file (uid, item) {
+ return h('.file',
+ h('.file-name',
+ h('a', u.getExtLinkName(item)),
+ h('.actions',
+ h('a', 'rename'),
+ h('a', 'delete')
+ )
+ ),
+ h('.file-size', u.bytesHuman(item.size)),
+ h('.file-date', (new Date(item.timestamp)).toLocaleDateString())
+ )
+}
+
+// :HACK: remove me
+function getNamespace (uid, cb) {
+ cb(null, [
+ { name: 'cats.png', timestamp: Date.now(), size: 1503 },
+ { name: 'WHOAMI.md', timestamp: Date.now()-100000, size: 51235 },
+ { name: 'index.html', timestamp: Date.now()-1000440, size: 2234 },
+ { name: 'index.js', timestamp: Date.now()-1001440, size: 35553 }
+ ])
+}
\ No newline at end of file
diff --git a/ui/lib/com/finder.js b/ui/lib/com/finder.js
new file mode 100644
index 0000000..1564135
--- /dev/null
+++ b/ui/lib/com/finder.js
@@ -0,0 +1,33 @@
+'use strict'
+var h = require('hyperscript')
+
+module.exports = function () {
+ var input = h('input', { onkeydown: onkeydown, placeholder: 'Search...' })
+ var finder = h('#finder', input)
+
+ function onkeydown (e) {
+ if (e.keyCode == 13) // enter
+ finder.find()
+ }
+ finder.find = find.bind(finder)
+ window.addEventListener('keydown', onwinkeydown)
+
+ function onwinkeydown (e) {
+ if (e.keyCode == 27) { // esc
+ window.removeEventListener('keydown', onwinkeydown)
+ finder.removeEventListener('keydown', onkeydown)
+ finder.parentNode.removeChild(finder)
+ }
+ }
+
+ return finder
+}
+
+function find () {
+ var el = this.querySelector('input')
+ var v = el.value
+ el.blur()
+ document.body.querySelector('#page').focus()
+ window.find(v,0,0,0,0,0,1)
+}
+
diff --git a/ui/lib/com/flag-form.js b/ui/lib/com/flag-form.js
new file mode 100644
index 0000000..401f15f
--- /dev/null
+++ b/ui/lib/com/flag-form.js
@@ -0,0 +1,91 @@
+var h = require('hyperscript')
+var suggestBox = require('suggest-box')
+var multicb = require('multicb')
+var schemas = require('ssb-msg-schemas')
+var app = require('../app')
+var ui = require('../ui')
+var modals = require('../ui/modals')
+var com = require('./index')
+var mentionslib = require('../mentions')
+
+module.exports = function (id, opts) {
+
+ // markup
+
+ var name = com.userName(id)
+ var textarea = h('textarea.form-control', { placeholder: 'Write your reason for flagging here.', rows: 4 })
+ suggestBox(textarea, app.suggestOptions)
+ var form = h('.flag-form',
+ h('h3', com.icon('flag'), ' Flag "', name, '"'),
+ h('p.text-muted', h('small', 'Warn your followers about this user.')),
+ h('form', { onsubmit: onsubmit },
+ h('.radios',
+ opt('old-account', 'Old account'),
+ opt('spammer', 'Spammer'),
+ opt('abusive', 'Abusive'),
+ opt('nsfw', 'NSFW'),
+ opt('other', 'Other')
+ ),
+ h('p', textarea),
+ h('p.text-right', h('button.btn.btn-3d', 'Publish'))
+ )
+ )
+
+ function opt (value, label) {
+ return h('.radio',
+ h('label',
+ h('input', { type: 'radio', name: 'flag-choice', value: value }),
+ label
+ )
+ )
+ }
+
+ // handlers
+
+ function onsubmit (e) {
+ e.preventDefault()
+
+ // prep text
+ ui.pleaseWait(true)
+ ui.setStatus('Publishing...')
+ var reason = textarea.value
+ var flag
+ try { flag = form.querySelector(':checked').value } catch (e) {}
+ mentionslib.extract(reason, function (err, mentions) {
+ if (err) {
+ ui.setStatus(null)
+ ui.pleaseWait(false)
+ if (err.conflict)
+ modals.error('Error While Publishing', 'You follow multiple people with the name "'+err.name+'." Go to the homepage to resolve this before publishing.')
+ else
+ modals.error('Error While Publishing', err, 'This error occurred while trying to extract the mentions from the text of a flag post.')
+ return
+ }
+
+ // publish
+ var done = multicb({ pluck: 1 })
+ app.ssb.publish(schemas.block(id), done())
+ app.ssb.publish(schemas.flag(id, flag||'other'), done())
+ done(function (err, msgs) {
+ if (err) {
+ ui.setStatus(null)
+ ui.pleaseWait(false)
+ return modals.error('Error While Publishing', err, 'This error occurred while trying to publish the block and flag messages.')
+ }
+
+ if (!reason) {
+ ui.setStatus(null)
+ ui.pleaseWait(false)
+ return opts.onsubmit()
+ }
+
+ app.ssb.publish(schemas.post(reason, msgs[1].key, msgs[1].key, (mentions.length) ? mentions : null), function (err) {
+ if (err) modals.error('Error While Publishing', err, 'This error occured while trying to publish the reason-post of a new flag.')
+ else opts.onsubmit()
+ })
+ })
+ })
+ }
+
+ return form
+}
\ No newline at end of file
diff --git a/ui/lib/com/help.js b/ui/lib/com/help.js
new file mode 100644
index 0000000..4da1dc0
--- /dev/null
+++ b/ui/lib/com/help.js
@@ -0,0 +1,230 @@
+var h = require('hyperscript')
+var com = require('./index')
+var app = require('../app')
+var modals = require('../ui/modals')
+var subwindows = require('../ui/subwindows')
+
+exports.helpBody = function (item) {
+ if (item == 'howto-pubs') {
+ return h('div', { style: 'padding: 20px' },
+ h('p',
+ h('strong', 'To get on the public mesh, you need a public node to follow you. '),
+ 'A public node is a usually a cloud server, but it could be any device with a public address. '
+ ),
+ h('br'),
+ h('p',
+ h('strong', 'During the alpha period, you have to know somebody that runs a public node. '),
+ 'Ask the node owner for an invite code, then ', com.a('#/sync', 'use the network-sync page'), ' to join their node.'
+ ),
+ h('br'),
+ h('p',
+ h('strong', 'If you\'re a neckbeard, you can set up a public node. '),
+ 'We have ', h('br'), h('a', { href: 'https://github.com/ssbc/scuttlebot', target: '_blank' }, 'detailed instructions'), ' available to help you get this done. '
+ )
+ )
+ }
+ if (item == 'howto-find-ppl') {
+ return h('div', { style: 'padding: 20px' },
+ h('p', h('strong', 'Method 1: Recommendations')),
+ h('p',
+ 'Click the ', com.icon('user'), ' icon in the homepage links to see ', h('a', { href: '#/friends' }, 'your friends page'), '. ',
+ 'It recommends users who your friends follow.'
+ ),
+ h('br'),
+ h('p', h('strong', 'Method 2: Search')),
+ h('p',
+ 'Try typing your friend\'s username into the location bar (top center). ',
+ 'They may show up in the results.'
+ ),
+ h('br'),
+ h('p', h('strong', 'Method 3: Send ID')),
+ h('p',
+ 'Have your friend send you their ID. ',
+ 'Then, put it in the location bar (top center) and press enter. ',
+ 'If you need to download their data, Patchwork will prompt you to do so.'
+ ),
+ h('p',
+ 'To find your ID, open ', com.a('#/profile/'+app.user.id, 'your profile'), ' and copy it out of the location bar.'
+ )
+ )
+ }
+ if (item == 'howto-posts') {
+ return h('div', { style: 'padding: 20px' },
+ h('p',
+ h('strong', 'Go to the ', com.a('#/news', 'news feed')), ', click on the input box at the top and start typing. ',
+ 'When you\'re happy with your post, press Publish.'
+ ),
+ h('br'),
+ h('p',
+ h('strong', 'Markdown is supported, and you can mention other users by typing @, then their username. '),
+ 'If the mentioned users follow you, they\'ll see the message in their inbox.'
+ ),
+ h('br'),
+ h('p',
+ h('strong', 'You can insert emojis with the : character. '),
+ 'Check the ', h('a', { href: 'http://www.emoji-cheat-sheet.com/', target: '_blank' }, 'Emoji Cheat Sheet'), ' to see what\'s available.',
+ h('.text-muted', { style: 'padding: 20px; padding-bottom: 10px' }, 'eg ":smile:" = ', h('img.emoji', { src: './img/emoji/smile.png', height: 20, width: 20}))
+ )
+ )
+ }
+ if (item == 'howto-webcam') {
+ return h('div', { style: 'padding: 20px' },
+ h('p',
+ h('strong', 'Go to the ', com.a('#/news', 'news feed')), ', then mouse over the ', com.icon('comment'), ' icon next to the input box at the top. ',
+ 'Click the ', com.icon('facetime-video'), ' icon to select the webcam tool.'
+ ),
+ h('br'),
+ h('p',
+ h('strong', 'Click and hold the video stream to record. '),
+ 'Alternatively, click the record 1/2/3s buttons to record for fixed durations. ',
+ 'You can record multiple times to put the clips together.'
+ ),
+ h('br'),
+ h('p',
+ h('strong', 'You can add a text message on the right. '),
+ 'As in text posts, you can put emojis and @-mentions in the text.'
+ )
+ )
+ }
+ if (item == 'howto-post-files') {
+ return h('div', { style: 'padding: 20px' },
+ h('p',
+ h('strong', 'Go to the ', com.a('#/news', 'news feed')), ', click on the input box at the top and start typing. ',
+ 'The input will expand, and you\'ll be shown a link to add attachments. ',
+ 'You can attach files up to 5MB.'
+ ),
+ h('br'),
+ h('p',
+ h('strong', 'If you want to embed a photo, attach it, then put an ! in front of the inserted link. '),
+ 'An example: '
+ ),
+ h('pre', '![my photo](&XXsJbhxj+kv1cAVJkc7jttb7/JFBkHYwMkQtxZmk+cQ=.sha256)')
+ )
+ }
+ if (item == 'secret-messages') {
+ return h('div', { style: 'padding: 20px' },
+ h('p',
+ h('strong', 'Secret Messages'), ' are completely private messages. ',
+ 'They are encrypted end-to-end, which means the network operators can not read them. ',
+ 'The recipients, subject, and content are hidden. '
+ ),
+ h('br'),
+ h('p',
+ h('strong', 'The recipients ', h('em', 'must'), ' follow you to see the message. '),
+ 'If you happen to send a message to someone that doesn\'t follow you, ',
+ 'then they\'ll receive the message once they do follow you.'
+ )
+ )
+ }
+ if (item == 'howto-secret-messages') {
+ return h('div', { style: 'padding: 20px' },
+ h('p',
+ 'Open your ', com.a('#/inbox', 'inbox page'), ' and click "Secret Message." ',
+ 'Then, add your recipients, write the message, and click Send.'
+ )
+ )
+ }
+}
+
+
+exports.helpTitle = function (item) {
+ return ({
+ 'howto-pubs': 'How do I get onto the public mesh?',
+ 'howto-find-ppl': 'How do I find people?',
+ 'howto-posts': 'How do I make a new post?',
+ 'howto-webcam': 'How do I make a webcam gif?',
+ 'howto-post-files': 'How do I post a file or photo?',
+ 'secret-messages': 'What are secret messages?',
+ 'howto-secret-messages': 'How do I send a secret message?',
+ })[item] || ''
+}
+
+exports.welcome = function () {
+ return h('.message',
+ h('span.user-img', h('img', { src: com.profilePicUrl(false) })),
+ h('.message-inner',
+ h('ul.message-header.list-inline', h('li', h('strong', 'Scuttlebot'))),
+ h('.message-body',
+ h('.markdown',
+ h('h3', 'Hello! And welcome to ', h('strong', 'Patchwork.')),
+ h('p',
+ 'Patchwork is an independent network of users. ',
+ 'The software is Free and Open-source, and the data is stored on your computer.'
+ ),
+ h('p', h('img.emoji', { src: './img/emoji/facepunch.png', height: 20, width: 20}), ' We fight for the user.')
+ )
+ )
+ ),
+ h('.message-comments',
+ h('.message',
+ h('span.user-img', h('img', { src: com.profilePicUrl(false) })),
+ h('.message-inner',
+ h('ul.message-header.list-inline', h('li', h('strong', 'Scuttlebot'))),
+ h('.message-body',
+ h('.markdown',
+ h('h4', 'Step 1: Join a public mesh node ', h('img.emoji', { src: './img/emoji/computer.png', height: 20, width: 20})),
+ h('p', 'To reach across the Internet, you need to belong to a public mesh node, also known as a ', h('strong', 'Pub'), '. '),
+ h('.text-center', { style: 'padding: 7px; background: rgb(238, 238, 238); margin-bottom: 10px; border-radius: 5px;' },
+ h('a.btn.btn-3d', { href: '#', onclick: modals.invite }, com.icon('cloud'), ' Join a Public Node')
+ )
+ )
+ )
+ )
+ ),
+ h('.message',
+ h('span.user-img', h('img', { src: com.profilePicUrl(false) })),
+ h('.message-inner',
+ h('ul.message-header.list-inline', h('li', h('strong', 'Scuttlebot'))),
+ h('.message-body',
+ h('.markdown',
+ h('h4', 'Step 2: Find your friends ', h('img.emoji', { src: './img/emoji/busts_in_silhouette.png', height: 20, width: 20})),
+ h('p', 'Have your friends send you their IDs so you can follow them. Paste the ID into the location bar, just like it\'s a URL.')
+ )
+ )
+ )
+ ),
+ h('.message',
+ h('span.user-img', h('img', { src: com.profilePicUrl(false) })),
+ h('.message-inner',
+ h('ul.message-header.list-inline', h('li', h('strong', 'Scuttlebot'))),
+ h('.message-body',
+ h('.markdown',
+ h('h4', 'Step 3: ', h('img.emoji', { src: './img/emoji/metal.png', height: 20, width: 20})),
+ h('p', 'You can publish ', h('strong', 'Messages and Files'), ' using the box at the top of your feed, and ', h('strong', 'Secret Messages'), ' via friends\' profile pages.')
+ )
+ )
+ )
+ )
+ )
+ )
+}
+
+exports.side = function () {
+ function onhelp (topic) {
+ return function (e) {
+ e.preventDefault()
+ subwindows.help(topic)
+ }
+ }
+
+ function help (topic, text) {
+ return [
+ h('a', { style: 'color: #555', href: '#', onclick: onhelp(topic), title: text }, com.icon('question-sign'), ' ', text),
+ h('br')
+ ]
+ }
+
+ return h('div',
+ h('strong', 'Help Topics:'), h('br'),
+ help('howto-pubs', 'How do I get onto the public mesh?'),
+ help('howto-find-ppl', 'How do I find people?'),
+ h('br'),
+ help('howto-posts', 'How do I make a new post?'),
+ help('howto-webcam', 'How do I make a webcam gif?'),
+ help('howto-post-files', 'How do I post a file or photo?'),
+ h('br'),
+ help('secret-messages', 'What are secret messages?'),
+ help('howto-secret-messages', 'How do I send a secret message?'),
+ help('howto-find-ppl', 'How do I read my messages?')
+ )
+}
\ No newline at end of file
diff --git a/ui/lib/com/image-uploader.js b/ui/lib/com/image-uploader.js
new file mode 100644
index 0000000..fa8fe67
--- /dev/null
+++ b/ui/lib/com/image-uploader.js
@@ -0,0 +1,221 @@
+var h = require('hyperscript')
+var NativeImage = require('native-image')
+var createHash = require('multiblob/util').createHash
+var pull = require('pull-stream')
+var pushable = require('pull-pushable')
+var app = require('../app')
+
+if (!('URL' in window) && ('webkitURL' in window))
+ window.URL = window.webkitURL
+
+module.exports = function (opts) {
+ opts = opts || {}
+
+ // markup
+
+ var fileInput = h('input', { type: 'file', accept: 'image/*', onchange: fileChosen })
+ var canvas = h('canvas', {
+ onmousedown: onmousedown,
+ onmouseup: onmouseup,
+ onmouseout: onmouseup,
+ onmousemove: onmousemove,
+ width: 275,
+ height: 275
+ })
+ var zoomSlider = h('input', { type: 'range', value: 0, oninput: onresize })
+ var existing = h('.image-uploader-existing', opts.existing ? h('img', { src: opts.existing }) : '')
+ var viewer = h('div', existing, fileInput)
+ var editormsg = h('small', 'drag to crop')
+ var editor = h('.image-uploader-editor',
+ { style: 'display: none' },
+ editormsg, h('br'),
+ canvas,
+ h('p', zoomSlider),
+ h('div',
+ h('button.btn.btn-3d.pull-right.savebtn', { onclick: onsave }, 'OK'),
+ h('button.btn.btn-3d', { onclick: oncancel }, 'Cancel')))
+ var el = h('.image-uploader', viewer, editor)
+ el.forceDone = forceDone.bind(el, opts)
+
+ // handlers
+
+ var img = h('img'), imgdim
+ var dragging = false, mx, my, ox=0, oy=0, zoom=1, minzoom=1
+
+ function draw () {
+ var ctx = canvas.getContext('2d')
+ ctx.globalCompositeOperation = 'source-over'
+ ctx.fillStyle = '#000'
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
+ ctx.drawImage(img, ox, oy, img.width * zoom, img.height * zoom)
+
+ if (dragging)
+ drawHexagonOverlay()
+ }
+ function drawHexagonOverlay () {
+ // hexagon coords (based on the behavior of the css hexagon)
+ var left = 20
+ var right = canvas.width - 20
+ var w12 = canvas.width / 2
+ var h14 = canvas.height / 4
+ var h34 = h14 * 3
+
+ var ctx = canvas.getContext('2d')
+ ctx.save()
+ ctx.fillStyle = '#fff'
+ ctx.globalAlpha = 0.75;
+ ctx.globalCompositeOperation = 'overlay'
+ ctx.beginPath()
+ ctx.moveTo(w12, 0)
+ ctx.lineTo(right, h14)
+ ctx.lineTo(right, h34)
+ ctx.lineTo(w12, canvas.height)
+ ctx.lineTo(left, h34)
+ ctx.lineTo(left, h14)
+ ctx.lineTo(w12, 0)
+ ctx.closePath()
+ ctx.fill()
+ ctx.restore()
+ }
+
+ function fileChosen (e) {
+ editor.style.display = 'block'
+ viewer.style.display = 'none'
+ editormsg.innerText = 'loading...'
+
+ // give the html renderer a turn before loading the image
+ // if the image is large, it'll block for a sec, and we want to render "loading..." first
+ setTimeout(function () {
+ var file = fileInput.files[0]
+ var ni = NativeImage.createFromPath(file.path)
+ img.src = ni.toDataUrl()
+
+ imgdim = ni.getSize()
+ var smallest = (imgdim.width < imgdim.height) ? imgdim.width : imgdim.height
+ ox = oy = 0
+ minzoom = zoom = 275/smallest
+ zoomSlider.value = 0
+
+ editormsg.innerText = 'drag to crop'
+ draw()
+ }, 100)
+
+ /*
+ :OLD: browser method, doesnt work in electron
+ var reader = new FileReader()
+ reader.onload = function (e) {
+ ox = oy = 0
+ zoom = 1
+ zoomSlider.value = 50
+ img.src = e.target.result
+
+ draw()
+ editor.style.display = 'block'
+ viewer.style.display = 'none'
+ }
+ reader.readAsDataURL(file)
+ */
+ }
+
+ function onmousedown (e) {
+ e.preventDefault()
+ dragging = true
+ mx = e.clientX
+ my = e.clientY
+ draw()
+ }
+
+ function onmouseup (e) {
+ e.preventDefault()
+ dragging = false
+ draw()
+ }
+
+ function onmousemove (e) {
+ e.preventDefault()
+ if (dragging) {
+ ox = Math.max(Math.min(ox + e.clientX - mx, 0), -imgdim.width * zoom + 275)
+ oy = Math.max(Math.min(oy + e.clientY - my, 0), -imgdim.height * zoom + 275)
+ draw()
+ mx = e.clientX
+ my = e.clientY
+ }
+ }
+
+ function onresize (e) {
+ zoom = minzoom + (zoomSlider.value / 100)
+ draw()
+ }
+
+ function onsave (e) {
+ e.preventDefault()
+ if (!opts.onupload)
+ throw "onupload not specified"
+
+ var hasher = createHash('sha256')
+ var ps = pushable()
+ pull(
+ ps,
+ hasher,
+ app.ssb.blobs.add(function (err) {
+ if(err)
+ return modals.error('Failed to Upload Image to Blobstore', err)
+
+ fileInput.value = ''
+ editor.style.display = 'none'
+ viewer.style.display = 'block'
+ opts.onupload(hasher)
+ })
+ )
+
+ // Send to sbot
+ var dataUrl = canvas.toDataURL('image/png')
+ existing.querySelector('img').setAttribute('src', dataUrl)
+ ps.push(NativeImage.createFromDataUrl(dataUrl).toPng())
+ ps.end()
+
+ /*
+ :OLD: browser method, doesnt work in electron
+ canvas.toBlob(function (blob) {
+ // Send to sbot
+ var reader = new FileReader()
+ reader.onloadend = function () {
+ ps.push(new Buffer(new Uint8Array(reader.result)))
+ ps.end()
+ }
+ reader.readAsArrayBuffer(blob)
+
+ // Update "existing" img
+ var blobUrl = URL.createObjectURL(blob)
+ existing.querySelector('img').setAttribute('src', blobUrl)
+ setTimeout(function() { URL.revokeObjectURL(blobUrl) }, 50) // give 50ms to render first
+ }, 'image/png')
+ */
+ }
+
+ function oncancel (e) {
+ e.preventDefault()
+ fileInput.value = ''
+ editor.style.display = 'none'
+ viewer.style.display = 'block'
+ }
+
+ return el
+}
+
+// helper to finish the edit in case the user forgets to press "OK"
+function forceDone (opts, cb) {
+ this.forceDone = null // detach for memory cleanup
+
+ // not editing?
+ if (this.querySelector('.image-uploader-editor').style.display != 'block')
+ return cb() // we're good
+
+ // update cb to run after onupload
+ var onupload = opts.onupload
+ opts.onupload = function (hasher) {
+ onupload(hasher)
+ cb()
+ }
+ this.querySelector('.savebtn').click() // trigger upload
+}
\ No newline at end of file
diff --git a/ui/lib/com/images-form.js b/ui/lib/com/images-form.js
new file mode 100644
index 0000000..9808e15
--- /dev/null
+++ b/ui/lib/com/images-form.js
@@ -0,0 +1,121 @@
+'use strict'
+var h = require('hyperscript')
+var o = require('observable')
+var mime = require('mime-types')
+var com = require('./index')
+var modals = require('../ui/modals')
+
+module.exports = function () {
+
+ var images = []
+
+ // markup
+
+ var filesInput = h('input.hidden', { type: 'file', accept: 'image/*', multiple: true, onchange: filesAdded })
+ var imagesListEl = h('.images-form-list', { onclick: onlistclick })
+ var form = h('.images-form',
+ imagesListEl,
+ h('.images-form-ctrls',
+ h('a.btn.btn-3d', { onclick: onadd, title: 'Add a new image to the album' }, '+ Add Image'),
+ h('a.btn.btn-primary.pull-right.disabled', 'Publish')
+ )
+ )
+
+ // handlers
+
+ function onadd (e) {
+ e.preventDefault()
+ filesInput.click()
+ }
+
+ function onlistclick (e) {
+ if (images.length == 0)
+ onadd(e)
+ }
+
+ function onremove (hash) {
+ return function (e) {
+ e.preventDefault()
+ images = images.filter(function (img) { return img.link != hash })
+ imagesListEl.removeChild(imagesListEl.querySelector('.image[data-hash="'+hash+'"]'))
+ }
+ }
+
+ function filesAdded (e) {
+ // hash the files
+ var n = filesInput.files.length
+ ui.setStatus('Hashing ('+n+' files left)...')
+ for (var i=0; i < n; i++) {
+ if (!add(filesInput.files[i])) {
+ ui.setStatus(false)
+ return
+ }
+ }
+ filesInput.value = null
+
+ function add (f) {
+ if (f.size > 5 * (1024*1024)) {
+ var inMB = Math.round(f.size / (1024*1024) * 100) / 100
+ modals.error('Error Attaching File', f.name + ' is larger than the 5 megabyte limit (' + inMB + ' MB)')
+ return false
+ }
+ app.ssb.patchwork.addFileToBlobs(f.path, function (err, res) {
+ if (--n === 0)
+ ui.setStatus(false)
+ if (err) {
+ modals.error('Error Attaching File', err, 'This error occurred while trying to add a file to the blobstore.')
+ } else {
+ for (var i=0; i < images.length; i++) {
+ if (images[i].link == res.hash)
+ return
+ }
+ images.push({
+ link: res.hash,
+ name: f.name,
+ desc: '',
+ size: f.size,
+ width: res.width,
+ height: res.height,
+ type: mime.lookup(f.name) || undefined
+ })
+ imagesListEl.appendChild(h('.image', { 'data-hash': res.hash },
+ h('.image-img', h('img', { src: 'http://localhost:7777/'+res.hash })),
+ h('.image-ctrls',
+ h('p',
+ f.name,
+ h('a.pull-right.text-danger', { href: '#', title: 'Remove this image from the album', onclick: onremove(res.hash) }, com.icon('remove'))
+ ),
+ h('textarea.form-control', { rows: 2, placeholder: 'Add a caption (optional)' })
+ )
+ ))
+ }
+ })
+ return true
+ }
+ }
+
+ return form
+}
+
+/*
+{
+ type: 'image-collection',
+ updates: {
+ link: MsgRef, // if this is an update, points to the original collection msg
+ deleted: Boolean // is the target deleted? defaults to false
+ },
+ title: String,
+ desc: String,
+ image: BlobLink, // the cover photo
+ includes: [{
+ link: BlobRef,
+ name: String,
+ desc: String,
+ size: Number, // in bytes
+ width: Number,
+ height: Number,
+ type: String // mimetype
+ }],
+ excludes: BlobLinks
+}
+*/
\ No newline at end of file
diff --git a/ui/lib/com/index.js b/ui/lib/com/index.js
new file mode 100644
index 0000000..22e481d
--- /dev/null
+++ b/ui/lib/com/index.js
@@ -0,0 +1,284 @@
+'use strict'
+var h = require('hyperscript')
+var o = require('observable')
+var pull = require('pull-stream')
+var app = require('../app')
+var ui = require('../ui')
+var modals = require('../ui/modals')
+var subwindows = require('../ui/subwindows')
+var u = require('../util')
+var social = require('../social-graph')
+var suggestBox = require('suggest-box')
+var ago = require('nicedate')
+
+var a =
+exports.a = function (href, text, opts) {
+ opts = opts || {}
+ opts.href = href
+ return h('a', opts, text)
+}
+
+var icon =
+exports.icon = function (i) {
+ return h('span.glyphicon.glyphicon-'+i)
+}
+
+var userlink =
+exports.userlink = function (id, text, opts) {
+ opts = opts || {}
+ opts.className = (opts.className || '') + ' user-link'
+ text = text || userName(id) || u.shortString(id)
+ return h('span.user-link-outer', a('#/profile/'+id, text, opts))
+}
+
+var user =
+exports.user = function (id, opts) {
+ var followIcon
+ if (id != app.user.id && (!app.user.profile.assignedTo[id] || !app.user.profile.assignedTo[id].following)) {
+ followIcon = [' ', h('a',
+ { title: 'This is not somebody you follow.', href: '#/profile/'+id },
+ h('span.text-muted', icon('question-sign'))
+ )]
+ }
+
+ var l = userlink
+ if (opts && opts.thin)
+ l = userlinkThin
+
+ var name = userName(id)
+ if (opts && opts.maxlength && name.length > opts.maxlength)
+ name = name.slice(0, opts.maxlength-3) + '...'
+
+ return [l(id, name), followIcon]
+}
+
+var userName =
+exports.userName = function (id) {
+ return app.users.names[id] || u.shortString(id)
+}
+
+var profilePicUrl =
+exports.profilePicUrl = function (id) {
+ var url = './img/default-prof-pic.png'
+ var profile = app.users.profiles[id]
+ if (profile) {
+ var link
+
+ // lookup the image link
+ if (profile.assignedBy[app.user.id] && profile.assignedBy[app.user.id].image)
+ link = profile.assignedBy[app.user.id].image
+ else if (profile.self.image)
+ link = profile.self.image
+
+ if (link) {
+ url = 'http://localhost:7777/'+link.link
+
+ // append the 'backup img' flag, so we always have an image
+ url += '?fallback=img'
+
+ // if we know the filetype, try to construct a good filename
+ if (link.type) {
+ var ext = link.type.split('/')[1]
+ if (ext) {
+ var name = app.users.names[id] || 'profile'
+ url += '&name='+encodeURIComponent(name+'.'+ext)
+ }
+ }
+ }
+ }
+ return url
+}
+
+var userImg =
+exports.userImg = function (id) {
+ return h('a.user-img', { href: '#/profile/'+id },
+ h('img', { src: profilePicUrl(id) })
+ )
+}
+
+var userlinkThin =
+exports.userlinkThin = function (id, text, opts) {
+ opts = opts || {}
+ opts.className = (opts.className || '') + 'thin'
+ return userlink(id, text, opts)
+}
+
+var hexagon =
+exports.hexagon = function (url, size) {
+ var img = url ? 'url('+url+')' : 'none'
+ size = size || 30
+ return h('.hexagon-'+size, { 'data-bg': url, style: 'background-image: '+img },
+ h('.hexTop'),
+ h('.hexBottom'))
+}
+
+var userHexagon =
+exports.userHexagon = function (id, size) {
+ return h('a.user-hexagon', { href: '#/profile/'+id },
+ hexagon(profilePicUrl(id), size)
+ )
+}
+
+var userRelationship =
+exports.userRelationship = function (id, nfollowers, nflaggers) {
+ if (id == app.user.id)
+ return 'This is you!'
+
+ // gather followers that you follow
+ if (typeof nfollowers == 'undefined')
+ nfollowers = social.followedFollowers(app.user.id, id).length
+ var summary
+ if (social.follows(app.user.id, id)) {
+ summary = 'Followed by you'
+ if (nfollowers > 0)
+ summary += ' and ' + nfollowers + ' user' + (nfollowers==1?'':'s') + ' you follow'
+ } else {
+ if (nfollowers === 0)
+ summary = 'Not followed by you or anyone you follow'
+ else
+ summary = 'Followed by ' + nfollowers + ' user' + (nfollowers==1?'':'s') + ' you follow'
+ }
+
+ // gather flaggers that you follow (and self)
+ if (typeof nflaggers == 'undefined')
+ nflaggers = social.followedFlaggers(app.user.id, id, true).length
+ if (nflaggers !== 0) {
+ summary += '. Flagged by '+nflaggers+' user' + (nflaggers==1?'':'s')
+ }
+
+ return summary
+}
+
+var hovercard =
+exports.hovercard = function (id) {
+ var name = userName(id)
+ var following = social.follows(app.user.id, id)
+ return h('.hovercard', { style: 'background-image: url('+profilePicUrl(id)+')' },
+ h('h3', userName(id)),
+ userRelationship(id),
+ (id != app.user.id) ? h('p', following ? 'You follow ' : 'You do not follow ', name) : ''
+ )
+}
+
+var userHexagrid =
+exports.userHexagrid = function (uids, opts) {
+ var nrow = (opts && opts.nrow) ? opts.nrow : 3
+ var size = (opts && opts.size) ? opts.size : 60
+
+ var els = [], row = []
+ uids.forEach(function (uid) {
+ row.push(userHexagon(uid, size))
+ var n = (opts && opts.uneven && els.length % 2 == 1) ? nrow-1 : nrow
+ if (row.length >= n) {
+ els.push(h('div', row))
+ row = []
+ }
+ })
+ if (row.length)
+ els.push(h('div', row))
+ return h('.user-hexagrid-'+size, els)
+}
+
+var friendsHexagrid =
+exports.friendsHexagrid = function (opts) {
+ var friends = []
+ friends.push(app.user.id)
+ for (var k in app.users.profiles) {
+ var p = app.users.profiles[k]
+ if (opts && opts.reverse) {
+ if (p.assignedTo[app.user.id] && p.assignedTo[app.user.id].following)
+ friends.push(p.id)
+ } else {
+ if (p.assignedBy[app.user.id] && p.assignedBy[app.user.id].following)
+ friends.push(p.id)
+ }
+ }
+ if (friends.length)
+ return userHexagrid(friends, opts)
+}
+
+exports.filterClasses = function () {
+ var cls = ''
+ if (!app.filters.nsfw)
+ cls += '.show-nsfw'
+ if (!app.filters.spam)
+ cls += '.show-spam'
+ if (!app.filters.abuse)
+ cls += '.show-abuse'
+ return cls
+}
+
+var nav =
+exports.nav = function (opts) {
+ var items = opts.items.map(function (item) {
+ var cls = '.navlink-'+item[0]
+ if (item[0] == opts.current)
+ cls += '.selected'
+ if (item[3])
+ cls += item[3]
+ if (typeof item[1] == 'function')
+ return h('a'+cls, { href: '#', 'data-item': item[0], onclick: item[1] }, item[2])
+ return h('a'+cls, { href: item[1] }, item[2])
+ })
+ return h('.navlinks', items)
+}
+
+var search =
+exports.search = function (opts) {
+ var searchInput = h('input.search', { type: 'text', name: 'search', placeholder: 'Search', value: opts.value })
+ return h('form', { onsubmit: opts.onsearch }, searchInput)
+}
+
+exports.paginator = function (base, start, count) {
+ var prevBtn = h('a.btn.btn-primary', { href: base+((start - 30 > 0) ? start - 30 : 0) }, icon('chevron-left'))
+ var nextBtn = h('a.btn.btn-primary', { href: base+(start+30) }, icon('chevron-right'))
+ if (start <= 0) prevBtn.setAttribute('disabled', true)
+ if (start+30 > count) nextBtn.setAttribute('disabled', true)
+ return h('p', prevBtn, (start + 1), ' - ', Math.min(count, (start + 30)), ' ('+count+')', nextBtn)
+}
+
+var panel =
+exports.panel = function (title, content) {
+ return h('.panel.panel-default', [
+ (title) ? h('.panel-heading', h('h3.panel-title', title)) : '',
+ h('.panel-body', content)
+ ])
+}
+
+var page =
+exports.page = function (id, content) {
+ return h('#page.container-fluid.'+id+'-page', content)
+}
+
+exports.prettyRaw = require('./pretty-raw')
+exports.messageFeed = require('./message-feed')
+exports.message = require('./message')
+exports.messageContent = require('./message-content')
+exports.messageSummary = require('./message-summary')
+exports.messageOneline = require('./message-oneline')
+exports.messageAttachments = require('./message-attachments')
+exports.messageStats = require('./message-stats')
+exports.contactFeed = require('./contact-feed')
+exports.contactPlaque = require('./contact-plaque')
+exports.contactListing = require('./contact-listing')
+exports.files = require('./files')
+exports.notifications = require('./notifications')
+exports.peers = require('./peers')
+exports.postForm = require('./post-form')
+exports.pmForm = require('./pm-form')
+exports.webcamGifferForm = require('./webcam-giffer-form')
+exports.imagesForm = require('./images-form')
+exports.composer = require('./composer')
+exports.imageUploader = require('./image-uploader')
+exports.inviteForm = require('./invite-form')
+exports.lookupForm = require('./lookup-form')
+exports.renameForm = require('./rename-form')
+exports.flagForm = require('./flag-form')
+exports.networkGraph = require('./network-graph')
+exports.connectionGraph = require('./connection-graph')
+exports.userDownloader = require('./user-downloader')
+exports.help = require('./help')
+exports.pagenav = require('./nav').pagenav
+exports.sidenav = require('./nav').sidenav
+exports.webview = require('./webview')
+exports.finder = require('./finder')
\ No newline at end of file
diff --git a/ui/lib/com/invite-form.js b/ui/lib/com/invite-form.js
new file mode 100644
index 0000000..d0f7ea8
--- /dev/null
+++ b/ui/lib/com/invite-form.js
@@ -0,0 +1,56 @@
+var h = require('hyperscript')
+var com = require('./index')
+
+module.exports = function (opts) {
+
+ // markup
+
+ var processingInfoText = h('p')
+ var processingInfo = h('.processing-info', h('.spinner', h('.cube1'), h('.cube2')), processingInfoText)
+ var errorText = h('span', 'Something went wrong!')
+ var error = h('.error.text-danger', com.icon('exclamation-sign'), ' ', errorText)
+ var useBtn = h('button.btn.btn-3d', 'Use Code')
+ var codeinput = h('input.form-control', { placeholder: 'Enter the invite code here' })
+ var form = h('.invite-form',
+ h('h3', 'Join a Public Node'),
+ h('form.form-inline', { onsubmit: function (e) { e.preventDefault(); opts.onsubmit(codeinput.value) } },
+ h('p', codeinput, useBtn)),
+ processingInfo,
+ error,
+ h('hr'),
+ h('p.text-muted', h('strong', 'Public nodes help you communicate across the Internet.')),
+ h('p.text-muted',
+ 'Neckbeards can setup their own public nodes. ',
+ h('a', { href: 'https://github.com/ssbc/scuttlebot', target: '_blank' }, 'Read the server documentation here.')
+ ),
+ h('p.text-muted',
+ 'Don\'t have an invite to a public node? During the closed beta, you\'ll have to find a pub owner and ask for one.'
+ )
+ )
+
+ // api
+
+ form.disable = function () {
+ useBtn.setAttribute('disabled', true)
+ codeinput.setAttribute('disabled', true)
+ }
+
+ form.enable = function () {
+ useBtn.removeAttribute('disabled')
+ codeinput.removeAttribute('disabled')
+ }
+
+ form.setProcessingText = function (text) {
+ error.style.display = 'none'
+ processingInfoText.innerHTML = text
+ processingInfo.style.display = 'block'
+ }
+
+ form.setErrorText = function (text) {
+ processingInfo.style.display = 'none'
+ errorText.innerHTML = text
+ error.style.display = 'block'
+ }
+
+ return form
+}
\ No newline at end of file
diff --git a/ui/lib/com/lookup-form.js b/ui/lib/com/lookup-form.js
new file mode 100644
index 0000000..c7c10cc
--- /dev/null
+++ b/ui/lib/com/lookup-form.js
@@ -0,0 +1,73 @@
+var h = require('hyperscript')
+var clipboard = require('clipboard')
+var com = require('./index')
+
+module.exports = function (opts) {
+
+ // markup
+
+ var processingInfoText = h('p')
+ var processingInfo = h('.processing-info', h('.spinner', h('.cube1'), h('.cube2')), processingInfoText)
+ var errorText = h('span', 'Something went wrong!')
+ var error = h('.error.text-danger', com.icon('exclamation-sign'), ' ', errorText)
+ var useBtn = h('button.btn.btn-3d', 'Find')
+ var codeinput = h('input.form-control', { placeholder: 'Enter your friend\'s lookup code here' })
+ var form = h('.lookup-form',
+ h('h3', 'Find a Friend'),
+ h('form.form-inline', { onsubmit: function (e) { e.preventDefault(); if (codeinput.value) { opts.onsubmit(codeinput.value) } } },
+ h('p', codeinput, useBtn)),
+ processingInfo,
+ error,
+ h('hr'),
+ h('p', h('strong', 'Lookup codes help you find users around the Internet.')),
+ h('.code',
+ h('p',
+ 'Your Lookup Code ',
+ h('a.btn.btn-3d.btn-xs.pull-right', { href: '#', onclick: oncopy }, com.icon('copy'), ' Copy to clipboard')
+ ),
+ h('p', h('input.form-control', { placeholder: 'Building...' }))
+ ),
+ h('.text-muted', 'Send this to your friends, so they can find you.')
+ )
+
+ // handlers
+
+ function oncopy (e) {
+ e.preventDefault()
+ var btn = e.target
+ if (btn.tagName == 'SPAN')
+ btn = e.path[1]
+ clipboard.writeText(form.querySelector('.code input').value)
+ btn.innerText = 'Copied!'
+ }
+
+ // api
+
+ form.disable = function () {
+ useBtn.setAttribute('disabled', true)
+ codeinput.setAttribute('disabled', true)
+ }
+
+ form.enable = function () {
+ useBtn.removeAttribute('disabled')
+ codeinput.removeAttribute('disabled')
+ }
+
+ form.setYourLookupCode = function (code) {
+ form.querySelector('.code input').value = code
+ }
+
+ form.setProcessingText = function (text) {
+ error.style.display = 'none'
+ processingInfoText.innerHTML = text
+ processingInfo.style.display = 'block'
+ }
+
+ form.setErrorText = function (text) {
+ processingInfo.style.display = 'none'
+ errorText.innerHTML = text
+ error.style.display = 'block'
+ }
+
+ return form
+}
\ No newline at end of file
diff --git a/ui/lib/com/message-attachments.js b/ui/lib/com/message-attachments.js
new file mode 100644
index 0000000..bfa6e5b
--- /dev/null
+++ b/ui/lib/com/message-attachments.js
@@ -0,0 +1,36 @@
+var h = require('hyperscript')
+var mlib = require('ssb-msgs')
+var querystring = require('querystring')
+var com = require('./index')
+var u = require('../util')
+
+var imageTypes = {
+ png: 'image/png',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ gif: 'image/gif',
+ svg: 'image/svg+xml'
+}
+function isImage (link) {
+ if (link.type && link.type.indexOf('image/') !== -1)
+ return true
+ if (link.name && imageTypes[link.name.split('.').slice(-1)[0].toLowerCase()])
+ return true
+}
+
+module.exports = function (msg) {
+ var els = []
+ mlib.indexLinks(msg.value.content, { ext: true }, function (link, rel) {
+ // var url = 'http://localhost:7777/'+link.link
+ // var qs = { name: u.getExtLinkName(link) }
+ // if (isImage(link))
+ // qs.fallback = 'img'
+ // url += querystring.stringify(qs)
+ var url = '#/webview/'+encodeURI(link.link)
+ if (isImage(link))
+ els.push(h('a', { href: url }, h('.image', { 'data-bg': 'http://localhost:7777/'+encodeURI(link.link), style: 'background-image: url(http://localhost:7777/'+encodeURI(link.link)+'?fallback=img)' })))
+ else
+ els.push(h('.file', h('a', { href: url }, com.icon('file'), ' ', link.name, ' ', h('small', (('size' in link) ? u.bytesHuman(link.size) : ''), ' ', link.type||''))))
+ })
+ return els.length ? h('.attachments', els) : undefined
+}
\ No newline at end of file
diff --git a/ui/lib/com/message-content.js b/ui/lib/com/message-content.js
new file mode 100644
index 0000000..9f3db01
--- /dev/null
+++ b/ui/lib/com/message-content.js
@@ -0,0 +1,157 @@
+'use strict'
+var h = require('hyperscript')
+var mlib = require('ssb-msgs')
+var schemas = require('ssb-msg-schemas')
+var ssbref = require('ssb-ref')
+var app = require('../app')
+var modals = require('../ui/modals')
+var com = require('./index')
+var markdown = require('../markdown')
+
+module.exports = function (msg) {
+ var c = msg.value.content
+
+ function md (str) {
+ return h('.markdown', { innerHTML: markdown.block(str, msg) })
+ }
+ try {
+ var s = ({
+ post: function () {
+ if (!c.text) return
+ var recps = mlib.links(c.recps).map(function (r, n) {
+ var user = com.user(r.link, { thin: true })
+ user[0].querySelector('.user-link').style.color = '#777'
+ if (n < c.recps.length-1)
+ return [user, ', ']
+ return user
+ })
+ if (recps && recps.length)
+ return h('div', h('p', 'To: ', recps), md(c.text))
+ return md(c.text)
+ },
+ contact: function () {
+ var subjects = mlib.links(c.contact).map(function (l) {
+ if (l.link === msg.value.author)
+ return 'self'
+ return com.user(l.link)
+ })
+ if (!subjects.length) return
+
+ if (c.following === true)
+ return h('h4', com.icon('user'), ' Followed ', subjects)
+ if (c.blocking === true)
+ return h('h4', com.icon(''), ' Blocked ', subjects)
+ if (c.following === false)
+ return h('h4', com.icon('minus'), ' Unfollowed ', subjects)
+ if (c.blocking === false)
+ return h('h4', com.icon('erase'), ' Unblocked ', subjects)
+ },
+ about: function () {
+ var about = mlib.link(c.about)
+ if (about.link == msg.value.author) {
+ if (c.image && c.name)
+ return h('h4', 'Set their image, and changed their name to ', c.name)
+ if (c.image)
+ return h('h4', 'Set their image')
+ if (c.name)
+ return h('h4', 'Changed their name to ', c.name)
+ } else {
+ if (c.name)
+ return h('h4', 'Set ', com.user(about.link), '\'s name to ', c.name)
+ }
+ },
+ vote: function () {
+ var items
+ var vote = mlib.link(c.vote)
+ if (!vote)
+ return
+
+ if (vote.value > 0)
+ items = [com.icon('star'), ' Starred ']
+ else if (vote.value <= 0)
+ items = [com.icon('erase'), ' Unstarred ']
+
+ if (ssbref.isMsgId(vote.link))
+ items.push(fetchMsgLink(vote.link))
+ else if (ssbref.isFeedId(vote.link))
+ items.push(com.user(vote.link))
+ else if (ssbref.isBlobId(vote.link))
+ items.push(com.a('#/webiew/'+vote.link, 'this file'))
+
+ return items
+ },
+ flag: function () {
+ var del
+ var flag = mlib.link(c.flag)
+ if (!flag)
+ return
+ if (app.user.id == msg.value.author) {
+ del = h('a.text-danger', { href: '#', onclick: onunflag, title: 'Remove this flag' }, h('small', com.icon('trash')))
+ function onunflag (e) {
+ e.preventDefault()
+ var p = del.parentNode
+ p.innerHTML = 'Flag removed'
+ p.classList.remove('text-danger')
+ p.classList.add('text-muted')
+
+ // publish unflag
+ app.ssb.publish(schemas.unflag(mlib.link(c.flag).link, msg.key), function (err, flagmsg) {
+ if (err) {
+ modals.error('Error While Publishing', err, 'This error occured while trying to publish an unflag.')
+ }
+ })
+ }
+ }
+
+ if (ssbref.isFeedId(flag.link)) {
+ var target = com.userlink(flag.link)
+ if (!flag.reason)
+ return h('h4.text-danger', com.icon('erase'), ' Unflagged ', target)
+ if (typeof flag.reason == 'string')
+ return h('h4.text-danger', com.icon('flag'), ' Flagged ', target, ' as ', h('span.label.label-danger', flag.reason))
+ return h('h4.text-danger', com.icon('flag'), ' Flagged ', target)
+ } else {
+ if (!flag.reason)
+ return h('p.text-danger', com.icon('erase'), ' Unflagged ', target)
+ if (typeof flag.reason == 'string')
+ return h('p.text-danger', com.icon('flag'), ' ', h('span.label.label-danger', flag.reason), ' ', target, ' ', del)
+ return h('p.text-danger', com.icon('flag'), ' Flagged ', target, ' ', del)
+ }
+ },
+ pub: function () {
+ var pub = mlib.link(c.pub)
+ if (pub)
+ return h('h4', com.icon('cloud'), ' Announced a public peer: ', com.user(pub.link), ' at ', pub.host, ':', pub.port)
+ }
+ })[c.type]()
+ if (!s || s.length == 0)
+ s = false
+ } catch (e) {console.log(e)}
+
+ if (!s)
+ s = h('table.raw', com.prettyRaw.table(msg.value.content))
+
+ return s
+}
+
+function fetchMsgLink (mid) {
+ var link = com.a('#/msg/'+mid, 'this post')
+ var linkspan = h('span', link)
+ app.ssb.get(mid, function (err, msg) {
+ if (msg) {
+ linkspan.insertBefore(h('span', (msg.author == app.user.id) ? 'your ' : com.userName(msg.author) + '\'s', ' post'), link)
+ link.style.display = 'block'
+ link.style.padding = '8px 0'
+ link.style.color = 'gray'
+ link.textContent = link.innerText = shorten((msg.content.type == 'post') ? msg.content.text : msg.content.type, 255)
+ }
+ })
+ return linkspan
+}
+
+function shorten (str, n) {
+ n = n || 120
+ if (str.length > n)
+ str = str.slice(0, n-3) + '...'
+ return str
+}
\ No newline at end of file
diff --git a/ui/lib/com/message-feed.js b/ui/lib/com/message-feed.js
new file mode 100644
index 0000000..ca4f1c3
--- /dev/null
+++ b/ui/lib/com/message-feed.js
@@ -0,0 +1,124 @@
+'use strict'
+var h = require('hyperscript')
+var mlib = require('ssb-msgs')
+var pull = require('pull-stream')
+var multicb = require('multicb')
+var app = require('../app')
+var com = require('../com')
+var u = require('../util')
+
+module.exports = function (opts) {
+ opts = opts || {}
+ var botcursor
+ var containerEl, feedEl
+ var fetching = false
+
+ if (!opts.feed)
+ opts.feed = app.ssb.createFeedStream
+ if (!opts.render)
+ opts.render = com.message
+
+ var cursor = opts.cursor
+ if (!cursor) {
+ cursor = function (msg) {
+ if (msg)
+ return [msg.value.timestamp, msg.value.author]
+ }
+ }
+
+ // markup
+
+ feedEl = h(opts.container||'.message-feed' + com.filterClasses())
+ containerEl = h('.message-feed-container', feedEl)
+
+ // message fetch
+
+ fetchBottom(function (n) {
+ if (opts.onempty && n === 0)
+ opts.onempty(feedEl)
+ })
+
+ function fetchBottom (cb) {
+ if (fetching) return
+ fetching = true
+
+ var numRendered = 0
+ fetchBottomBy(opts.limit||30)
+ function fetchBottomBy (amt) {
+ var lastmsg
+ var renderedKeys = []
+ pull(
+ opts.feed({ reverse: true, limit: amt||30, lt: cursor(botcursor) }),
+ pull.drain(function (msg) {
+ lastmsg = msg
+
+ // filter
+ if (opts.filter && !opts.filter(msg))
+ return
+
+ // render
+ var el = opts.render(msg)
+ if (el) {
+ feedEl.appendChild(el)
+ renderedKeys.push(msg.key)
+ numRendered++
+ }
+ }, function (err) {
+ if (err)
+ console.warn('Error while fetching messages', err)
+
+ // nothing new? stop
+ if (!lastmsg || (botcursor && botcursor.key == lastmsg.key)) {
+ fetching = false
+ return (cb && cb(numRendered))
+ }
+ botcursor = lastmsg
+
+ if (opts.markread)
+ app.ssb.patchwork.markRead(renderedKeys)
+
+ // fetch more if needed
+ var remaining = amt - renderedKeys.length
+ if (remaining > 0 && !opts.onefetch)
+ return fetchBottomBy(remaining)
+
+ // we're done
+ fetching = false
+ cb && cb(numRendered)
+ })
+ )
+ }
+ }
+
+ if (opts.live) {
+ pull(
+ opts.live,
+ pull.drain(function (msg) {
+ // filter
+ if (opts.filter && !opts.filter(msg))
+ return
+
+ // render
+ var el = opts.render(msg)
+ if (el) {
+ feedEl.insertBefore(el, feedEl.firstChild)
+ if (opts.markread)
+ app.ssb.patchwork.markRead(msg.key)
+ }
+ })
+ )
+ }
+
+ // behaviors
+
+ if (opts.infinite) {
+ window.onscroll = function (e) {
+ if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) {
+ // hit bottom
+ fetchBottom()
+ }
+ }
+ }
+
+ return containerEl
+}
\ No newline at end of file
diff --git a/ui/lib/com/message-oneline.js b/ui/lib/com/message-oneline.js
new file mode 100644
index 0000000..3a84e2e
--- /dev/null
+++ b/ui/lib/com/message-oneline.js
@@ -0,0 +1,138 @@
+'use strict'
+var h = require('hyperscript')
+var mlib = require('ssb-msgs')
+var app = require('../app')
+var com = require('./index')
+var u = require('../util')
+
+function getSummary (msg, opts) {
+ var c = msg.value.content
+ var maxlen = (opts && opts.menuitem) ? 40 : 100
+ function t(text) {
+ if (text.length > maxlen)
+ return text.slice(0, maxlen-3) + '...'
+ return text
+ }
+
+ try {
+ var s = ({
+ post: function () {
+ if (!c.text) return
+ if (mlib.link(c.root, 'msg'))
+ return com.a('#/msg/'+mlib.link(c.root).link + '?jumpto=' + encodeURIComponent(msg.key), t(c.text))
+ return com.a('#/msg/'+msg.key, t(c.text))
+ },
+ mail: function () {
+ return com.a('#/msg/'+msg.key, [c.subject||'(No Subject)', ' ', h('span.text-muted', t(c.body))])
+ },
+ vote: function () {
+ if (!mlib.link(c.vote, 'msg'))
+ return
+ var link, desc = h('span', 'this message')
+ if (c.vote == 1)
+ link = h('a', { href: '#/msg/'+mlib.link(c.vote).link }, com.icon('star'), ' ', desc)
+ else if (c.vote <= 0)
+ link = h('a', { href: '#/msg/'+mlib.link(c.vote).link }, com.icon('erase'), ' ', desc)
+ appendMsgSummary(desc, mlib.link(c.vote).link, t)
+ return link
+ },
+ flag: function () {
+ if (!mlib.link(c.flag, 'msg'))
+ return
+ if (c.flag)
+ return com.a('#/msg/'+mlib.link(c.flag).link, h('span.text-danger', com.icon('flag'), ' Flagged your post ', h('span.label.label-danger', c.flag)))
+ else
+ return com.a('#/msg/'+mlib.link(c.flag).link, h('span.text-danger', com.icon('erase'), ' Unflagged your post'))
+ }
+ })[c.type]()
+ if (!s || s.length == 0)
+ s = false
+ } catch (e) { }
+
+ if (!s)
+ s = h('div', t(JSON.stringify(msg.value.content)))
+ return s
+}
+
+module.exports = function (msg, opts) {
+
+ // markup
+
+ var content
+ if (typeof msg.value.content == 'string') {
+ // encrypted message, try to decrypt
+ content = h('div')
+ app.ssb.private.unbox(msg.value.content, function (err, decrypted) {
+ if (decrypted) {
+ // success, render content
+ msg.value.content = decrypted
+ var col = content.parentNode
+ var icon = com.icon('lock')
+ icon.style.marginRight = '5px'
+ col.removeChild(content)
+ col.appendChild(icon)
+ col.appendChild(getSummary(msg, opts))
+ }
+ })
+ } else
+ content = getSummary(msg, opts)
+ if (!content)
+ return
+
+ var msgOneline
+ if (opts && opts.menuitem) {
+ // get the href for the link
+ var c = msg.value.content
+ var href = '#/msg/'
+ if (mlib.link(c.root, 'msg'))
+ href += mlib.link(c.root).link + '?jumpto=' + encodeURIComponent(msg.key)
+ else if (mlib.link(c.voteTopic, 'msg'))
+ href += mlib.link(c.voteTopic).link
+ else
+ href += msg.key
+
+ // render based on type
+ if (c.type == 'vote') {
+ msgOneline = h('a.message-oneline-menuitem', { href: href },
+ h('.message-oneline-column.only', h('strong', com.userName(msg.value.author)), ' ', content)
+ )
+ } else {
+ msgOneline = h('a.message-oneline-menuitem', { href: href },
+ h('.message-oneline-column', h('strong', com.userName(msg.value.author))),
+ h('.message-oneline-column', content),
+ h('.message-oneline-column', ago(msg))
+ )
+ }
+ } else {
+ msgOneline = h('.message-oneline',
+ h('.message-oneline-column', com.userImg(msg.value.author)),
+ h('.message-oneline-column', com.user(msg.value.author, { maxlength: 15 })),
+ h('.message-oneline-column', content),
+ h('.message-oneline-column', ago(msg))
+ )
+ }
+
+ app.ssb.patchwork.isRead(msg.key, function (err, isread) {
+ if (!err && !isread)
+ msgOneline.classList.add('unread')
+ })
+
+ return msgOneline
+}
+
+function appendMsgSummary (el, mid, shorten) {
+ app.ssb.get(mid, function (err, msg) {
+ if (!msg) return
+ if (msg.content.type == 'post')
+ el.textContent = shorten(msg.content.text)
+ else if (msg.content.type == 'mail')
+ el.textContent = shorten(msg.content.subject)
+ })
+}
+
+function ago (msg) {
+ var str = u.prettydate(new Date(msg.value.timestamp))
+ if (str == 'yesterday')
+ str = '1d'
+ return h('small.text-muted', str, ' ago')
+}
\ No newline at end of file
diff --git a/ui/lib/com/message-stats.js b/ui/lib/com/message-stats.js
new file mode 100644
index 0000000..26e63a0
--- /dev/null
+++ b/ui/lib/com/message-stats.js
@@ -0,0 +1,92 @@
+var h = require('hyperscript')
+var schemas = require('ssb-msg-schemas')
+var app = require('../app')
+var com = require('./index')
+var modals = require('../ui/modals')
+var u = require('../util')
+
+var hexagridOpts = { size: 30, nrow: 10 }
+module.exports = function (msg, opts) {
+
+ var stats = (msg) ? u.calcMessageStats(msg, opts) : {}
+
+ // markup
+
+ var upvoted = (stats.uservote === 1) ? '.selected' : ''
+ var downvoted = (stats.uservote === -1) ? '.selected' : ''
+ var upvote = h('a.upvote'+upvoted, { href: '#', onclick: (opts && opts.handlers) ? onupvote : null }, com.icon('triangle-top'))
+ var downvote = h('a.downvote'+downvoted, { href: '#', onclick: (opts && opts.handlers) ? ondownvote : null }, com.icon('triangle-bottom'))
+ var voteTally = h('span.vote-tally', { 'data-amt': stats.voteTally||0 })
+
+ // up/down voter hexagrids
+ var upvoters = [], downvoters = []
+ if (stats.votes) {
+ for (var uid in stats.votes) {
+ var v = stats.votes[uid]
+ if (v === 1) upvoters.push(uid)
+ if (v === -1) downvoters.push(uid)
+ }
+ }
+
+ var upvotersGrid, downvotersGrid
+ if (upvoters.length) {
+ upvotersGrid = com.userHexagrid(upvoters, hexagridOpts)
+ upvotersGrid.classList.add('upvoters')
+ }
+ if (downvoters.length) {
+ downvotersGrid = com.userHexagrid(downvoters, hexagridOpts)
+ downvotersGrid.classList.add('downvoters')
+ }
+
+ return h('.message-stats',
+ h('div',
+ h('span.stat.votes', upvote, voteTally, downvote),
+ h('a.stat.comments', { href: (msg) ? '#/msg/'+msg.key : 'javascript:void(0)', 'data-amt': stats.comments||0 }, com.icon('comment'))),
+ upvotersGrid,
+ downvotersGrid
+ )
+
+ // handlers
+
+ function onupvote (e) {
+ vote(e, upvote, 1)
+ }
+
+ function ondownvote (e) {
+ vote(e, downvote, -1)
+ }
+
+ var voting = false
+ function vote (e, el, btnVote) {
+ e.preventDefault()
+ e.stopPropagation()
+ if (voting)
+ return // wait please
+ voting = true
+
+ // get current state by checking if the control is selected
+ // this won't always be the most recent info, but it will be close and harmless to get wrong,
+ // plus it will reflect what the user expects to happen happening
+ var wasSelected = el.classList.contains('selected')
+ var newvote = (wasSelected) ? 0 : btnVote // toggle behavior: unset
+ el.classList.toggle('selected') // optimistice ui update
+ // :TODO: use msg-schemas
+ app.ssb.publish(schemas.vote(msg.key, newvote), function (err) {
+ voting = false
+ if (err) {
+ el.classList.toggle('selected') // undo
+ modals.error('Error While Publishing', err)
+ } else {
+ // update ui
+ var delta = newvote - (stats.uservote || 0)
+ voteTally.dataset.amt = stats.voteTally = stats.voteTally + delta
+ stats.uservote = newvote
+
+ var up = (newvote === 1) ? 'add' : 'remove'
+ var down = (newvote === -1) ? 'add' : 'remove'
+ upvote.classList[up]('selected')
+ downvote.classList[down]('selected')
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/ui/lib/com/message-summary.js b/ui/lib/com/message-summary.js
new file mode 100644
index 0000000..7c65fa4
--- /dev/null
+++ b/ui/lib/com/message-summary.js
@@ -0,0 +1,141 @@
+'use strict'
+var h = require('hyperscript')
+var pull = require('pull-stream')
+var mlib = require('ssb-msgs')
+var ssbref = require('ssb-ref')
+var multicb = require('multicb')
+var app = require('../app')
+var com = require('./index')
+var u = require('../util')
+var markdown = require('../markdown')
+
+function shorten (str, n) {
+ n = n || 120
+ if (str.length > n)
+ str = str.slice(0, n-3) + '...'
+ return str
+}
+
+function getSummary (msg) {
+ var c = msg.value.content
+
+ function md (str) {
+ return h('.markdown', { innerHTML: markdown.block(str, msg) })
+ }
+ try {
+ var s = ({
+ init: function () {
+ return [com.icon('off'), ' created account.']
+ },
+ post: function () {
+ if (!c.text) return
+ if (mlib.link(c.root, 'msg'))
+ return [com.icon('share-alt'), ' replied ', ago(msg), h('a.msg-link', { style: 'color: #555', href: '#/msg/'+mlib.link(c.root).link }, shorten(c.text, 255))]
+ if (mlib.links(c.mentions).filter(function(link) { return mlib.link(link).link == app.user.id }).length)
+ return [com.icon('hand-right'), ' mentioned you ', ago(msg), h('a.msg-link', { style: 'color: #555', href: '#/msg/'+msg.key }, shorten(c.text, 255))]
+ return md(c.text)
+ },
+ pub: function () {
+ return [com.icon('cloud'), ' announced a public peer at ', c.address]
+ },
+ contact: function () {
+ var subjects = mlib.links(c.contact).map(function (l) {
+ if (l.link === msg.value.author)
+ return 'self'
+ if (l.link === app.user.id)
+ return 'you'
+ return com.user(l.link)
+ })
+ if (!subjects.length) return
+
+ var items = []
+ if (c.following === true)
+ items.push(['followed ', subjects])
+ else if (c.blocking === true)
+ items.push(['blocked ', subjects])
+ else if (c.following === false)
+ items.push(['unfollowed ', subjects])
+ else if (c.blocking === false)
+ items.push(['unblocked ', subjects])
+
+ if (items.length===0)
+ return
+ items.push([' ', ago(msg)])
+ return items
+ },
+ vote: function () {
+ var items
+ var vote = mlib.link(c.vote)
+ if (!vote)
+ return
+
+ if (vote.value > 0)
+ items = [com.icon('star'), ' Starred ']
+ else if (vote.value <= 0)
+ items = [com.icon('erase'), ' Unstarred ']
+
+ if (ssbref.isMsgId(vote.link))
+ items.push(fetchMsgLink(vote.link))
+ else if (ssbref.isFeedId(vote.link))
+ items.push(com.user(vote.link))
+ else if (ssbref.isBlobId(vote.link))
+ items.push(com.a('#/webiew/'+vote.link, 'this file'))
+
+ return items
+ }
+ })[c.type]()
+ if (!s || s.length == 0)
+ s = false
+ return s
+ } catch (e) { console.log(e); return '' }
+}
+
+module.exports = function (msg, opts) {
+
+ // markup
+
+ var content = getSummary(msg, opts)
+ if (!content)
+ return
+
+ var msgSummary = h('.message-summary',
+ com.userImg(msg.value.author),
+ h('.message-summary-content', com.user(msg.value.author), ' ', content)
+ )
+
+ return msgSummary
+
+}
+
+module.exports.raw = function (msg, opts) {
+ // markup
+
+ var msgSummary = h('.message-summary',
+ com.userImg(msg.value.author),
+ h('.message-summary-content',
+ com.user(msg.value.author), ' ', ago(msg), ' ', h('small.pull-right', com.a('#/msg/'+msg.key, msg.key)),
+ h('table.raw', com.prettyRaw.table(msg.value.content)
+ ))
+ )
+
+ return msgSummary
+}
+
+function ago (msg) {
+ var str = u.prettydate(new Date(msg.value.timestamp))
+ if (str == 'yesterday')
+ str = '1d'
+ return h('small.text-muted', str, ' ago')
+}
+
+function fetchMsgLink (mid) {
+ var link = h('a.msg-link', { href: '#/msg/'+mid }, 'this message')
+ app.ssb.get(mid, function (err, msg) {
+ if (msg) {
+ console.log(msg)
+ var str = (msg.content.type == 'post') ? msg.content.text : ('this '+msg.content.type)
+ link.innerHTML = markdown.block(str, { key: mid, value: msg })
+ }
+ })
+ return link
+}
\ No newline at end of file
diff --git a/ui/lib/com/message.js b/ui/lib/com/message.js
new file mode 100644
index 0000000..ff059f1
--- /dev/null
+++ b/ui/lib/com/message.js
@@ -0,0 +1,394 @@
+'use strict'
+var h = require('hyperscript')
+var pull = require('pull-stream')
+var paramap = require('pull-paramap')
+var mlib = require('ssb-msgs')
+var schemas = require('ssb-msg-schemas')
+var ssbref = require('ssb-ref')
+var app = require('../app')
+var ui = require('../ui')
+var modals = require('../ui/modals')
+var com = require('./index')
+var u = require('../util')
+var markdown = require('../markdown')
+var social = require('../social-graph')
+
+module.exports = function (msg, opts) {
+
+ // markup
+
+ msg.plaintext = (typeof msg.value.content !== 'string')
+ var msgComments = h('.message-comments')
+ var msgEl = h('.message'+(!msg.plaintext?'.secret':'')+((opts && opts.fullview)?'.fullview':'.smallview'),
+ { onclick: (!(opts && opts.fullview) ? onopen(msg) : null) },
+ com.userImg(msg.value.author),
+ h('.message-inner',
+ h('ul.message-header.list-inline',
+ h('li', com.user(msg.value.author)),
+ (mlib.link(msg.value.content.root)) ? h('li', h('em', h('a.text-muted', { href: '#/msg/'+mlib.link(msg.value.content.root).link }, 'replies to...'))) : '',
+ !msg.plaintext ? h('li', com.icon('lock')) : '',
+ h('li.pull-right', h('a', { href: '#', onclick: onflag(msg), title: 'Flag this post' }, com.icon('flag'))),
+ h('li.favorite.pull-right',
+ h('span.users'),
+ h('a', { href: '#', onclick: onfavorite(msg), title: 'Favorite this post' }, com.icon('star'))
+ )
+ ),
+ h('.message-body', (typeof msg.value.content != 'string') ? com.messageContent(msg) : ''),
+ h('ul.message-footer.list-inline',
+ (!(opts && opts.fullview)) ?
+ h('li', com.a('#/msg/'+msg.key, h('small.comment-count-digits'))) :
+ '',
+ h('li.pull-right', h('small', com.a('#/msg/'+msg.key, u.prettydate(new Date(msg.value.timestamp), true))))
+ )
+ ),
+ msgComments
+ )
+ msg.el = msgEl // attach el to msg for the handler-funcs to access
+ fetchState(msg, opts)
+
+ // unread
+ app.ssb.patchwork.isRead(msg.key, function (err, isread) {
+ if (!err && !isread)
+ msg.el.classList.add('unread')
+ })
+
+ // if encrypted, attempt to decrypt
+ if (!msg.plaintext) {
+ app.ssb.private.unbox(msg.value.content, function (err, decrypted) {
+ if (decrypted) {
+ msg.value.content = decrypted
+
+ // render content
+ var body = msgEl.querySelector('.message-body')
+ body.innerHTML = ''
+ body.appendChild(com.messageContent(msg))
+ }
+ })
+ }
+
+ if (opts && opts.live) {
+ // create a live-stream of the log
+ var livelog = app.ssb.createLogStream({ gt: Date.now(), live: true })
+ ui.onTeardown(function() { livelog(true, function(){}) })
+ pull(livelog, pull.drain(function (newmsg) {
+ if (newmsg.sync) return
+
+ // decrypt, if needed
+ newmsg.plaintext = (typeof newmsg.value.content !== 'string')
+ if (!newmsg.plaintext) {
+ app.ssb.private.unbox(newmsg.value.content, function (err, decrypted) {
+ if (decrypted) {
+ newmsg.value.content = decrypted
+ next()
+ }
+ })
+ } else next()
+
+ function next () {
+ var c = newmsg.value.content
+ // only messages in this thread
+ if (!(c.type && c.root && mlib.link(c.root).link == msg.key))
+ return
+
+ // render new comments automatically
+ var el = renderComment(newmsg)
+ el.classList.add('add-anim')
+ setTimeout(function() {
+ el.querySelector('.message-inner').style.background = '#fff'
+ }, 33)
+ msg.el.querySelector('.message-comments').appendChild(el)
+ }
+ }))
+
+ // render a notice that this is live
+ msg.el.appendChild(h('.well.text-muted', { style: 'margin: 5px 0 0 88px' }, com.icon('flash'), ' Replies will auto-update in realtime.'))
+ }
+
+ return msgEl
+}
+
+function onpostreply (msg, opts) {
+ return function (comment) {
+ if (opts && opts.fullview)
+ return
+ if (typeof comment.value.content == 'string') // an encrypted message?
+ ui.refreshPage() // easier just to refresh to page, for now
+ else
+ msg.el.querySelector('.message-comments').appendChild(renderComment(comment))
+ }
+}
+
+function onopen (msg) {
+ return function (e) {
+ // make sure this isnt a click on a link
+ var node = e.target
+ while (node && node !== msg.el) {
+ if (node.tagName == 'A')
+ return
+ node = node.parentNode
+ }
+
+ e.preventDefault()
+ e.stopPropagation()
+
+ var root = mlib.link(msg.value.content.root || msg.value.content.flag)
+ var key = root ? root.link : msg.key
+ window.location.hash = '#/msg/'+key+((key!=msg.key)?('?jumpto='+encodeURIComponent(msg.key)):'')
+ }
+}
+
+function onfavorite (msg) {
+ var voting = false
+ return function (e) {
+ e.preventDefault()
+ e.stopPropagation()
+
+ if (voting)
+ return // wait please
+ voting = true
+ var favoriteBtn = this
+
+ // get current state by checking if the control is selected
+ // this won't always be the most recent info, but it will be close and harmless to get wrong,
+ // plus it will reflect what the user expects to happen happening
+ var wasSelected = favoriteBtn.classList.contains('selected')
+ var newvote = (wasSelected) ? 0 : 1
+ updateFavBtn(favoriteBtn, !wasSelected)
+ app.ssb.publish(schemas.vote(msg.key, newvote), function (err) {
+ voting = false
+ if (err) {
+ updateFavBtn(favoriteBtn, wasSelected) // undo
+ modals.error('Error While Publishing', err, 'This error occured while trying to fav/unfav message.')
+ } else {
+ // update ui
+ var users = msg.el.querySelector('.message-header .favorite .users')
+ if (newvote === 0) {
+ try { users.removeChild(users.querySelector('.this-user')) } catch (e) {}
+ } else {
+ var userimg = com.userImg(app.user.id)
+ userimg.classList.add('this-user')
+ users.insertBefore(userimg, users.firstChild)
+ }
+ }
+ })
+ }
+}
+
+function onflag (msg) {
+ return function (e) {
+ e.preventDefault()
+ e.stopPropagation()
+ ui.dropdown(e.target, [
+ { value: 'nsfw', label: 'NSFW', title: 'Graphic or adult content' },
+ { value: 'spam', label: 'Spam', title: 'Off-topic or nonsensical' },
+ { value: 'abuse', label: 'Abuse', title: 'Harrassment or needlessly derogatory' }
+ ], function (value) {
+ if (!value) return
+ // publish flag
+ app.ssb.publish(schemas.flag(msg.key, value), function (err, flagmsg) {
+ if (err) {
+ modals.error('Error While Publishing', err, 'This error occured while trying to flag a message.')
+ } else {
+ // render new flag
+ msg.el.querySelector('.message-comments').appendChild(renderComment(flagmsg))
+ }
+ })
+ })
+ }
+}
+
+function updateFavBtn (el, b) {
+ if (b)
+ el.classList.add('selected')
+ else
+ el.classList.remove('selected')
+ el.setAttribute('title', b ? 'Unfavorite this post' : 'Favorite this post')
+}
+
+var fetchState =
+module.exports.fetchState = function (msg, opts) {
+ // reply messages
+ app.ssb.relatedMessages({ id: msg.key, count: true }, function (err, thread) {
+ if (!thread || !thread.related) {
+ if (opts && opts.fullview)
+ msg.el.appendChild(com.composer(msg, msg, { onpost: onpostreply(msg, opts) }))
+ if (opts && opts.markread)
+ app.ssb.patchwork.markRead(msg.key)
+ return
+ }
+
+ u.decryptThread(thread, function () {
+ // copy the original message's value over, in case it was decrypted above
+ thread.value = msg.value
+
+ // handle votes, flags
+ renderSignals(msg.el, thread)
+
+ // get comments
+ var cids = {}
+ var comments = thread.related.filter(function (r) {
+ if (cids[r.key]) return false // only appear once
+ cids[r.key] = 1
+ var c = r.value.content
+ if (c.type == 'flag' && c.flag && c.flag.reason && !isFlagUndone(r))
+ return true // render a flag if it's still active
+ return (c.type == 'post') && isaReplyTo(r, msg)
+ })
+
+ // render composer now that we know the last message, and thus can give the branch link
+ if (opts && opts.fullview)
+ msg.el.appendChild(com.composer(thread, comments[comments.length - 1] || thread, { onpost: onpostreply(msg, opts) }))
+
+ // render comments
+ if (opts && opts.fullview)
+ renderComments()
+ else {
+ if (opts && opts.markread)
+ app.ssb.patchwork.markRead(thread.key) // go ahead and mark the root read
+ if (comments.length)
+ msg.el.querySelector('.comment-count-digits').innerText = comments.length + (comments.length == 1?' reply':' replies')
+ }
+ function renderComments (e) {
+ e && e.preventDefault()
+
+ // render
+ var commentsEl = msg.el.querySelector('.message-comments')
+ var existingCommentEl = commentsEl.firstChild
+ comments.forEach(function (comment) {
+ commentsEl.insertBefore(renderComment(comment), existingCommentEl)
+ })
+
+ // mark read
+ if (opts && opts.markread) {
+ var ids = [thread.key].concat(comments.map(function (c) { return c.key }))
+ app.ssb.patchwork.markRead(ids)
+ }
+ }
+ })
+ })
+}
+
+function renderComment (msg, encryptionNotice) {
+ var el = h('.message',
+ { 'data-key': msg.key },
+ com.userImg(msg.value.author),
+ h('.message-inner',
+ h('ul.message-header.list-inline',
+ h('li', com.user(msg.value.author)),
+ h('li', h('small', com.a('#/msg/'+msg.key, u.prettydate(new Date(msg.value.timestamp), true)))),
+ (msg.plaintext === false) ? h('li', com.icon('lock')) : '',
+ h('li.pull-right', h('a', { href: '#', onclick: onflag(msg), title: 'Flag this post' }, com.icon('flag'))),
+ h('li.favorite.pull-right',
+ h('span.users'),
+ h('a', { href: '#', onclick: onfavorite(msg), title: 'Favorite this post' }, com.icon('star'))
+ )
+ ),
+ h('.message-body',
+ ((encryptionNotice) ?
+ (msg.plaintext ?
+ h('em.text-danger.pull-right', 'Warning: This comment was not encrypted!') :
+ h('span.pull-right', com.icon('lock')))
+ : ''),
+ com.messageContent(msg)
+ ),
+ h('ul.message-footer.list-inline',
+ h('li.pull-right', h('small', com.a('#/msg/'+msg.key, u.prettydate(new Date(msg.value.timestamp), true))))
+ )
+ )
+ )
+ msg.el = el // attach for handlers
+ renderSignals(el, msg)
+
+ // unread
+ app.ssb.patchwork.isRead(msg.key, function (err, isread) {
+ if (!err && !isread)
+ msg.el.classList.add('unread')
+ })
+
+ return el
+}
+
+function isaReplyTo (a, b) {
+ var c = a.value.content
+ return (c.root && mlib.link(c.root).link == b.key || c.branch && mlib.link(c.branch).link == b.key)
+}
+function isaMentionOf (a, b) {
+ var c = a.value.content
+ return mlib.links(c.mentions).filter(function(l) { return l.link == b.key }).length !== 0
+}
+
+function renderSignals (el, msg) {
+ if (!msg || !msg.related)
+ return
+
+ // collect mentions and votes
+ var mentions = []
+ var upvoters = {}, flaggers = {}
+ msg.related.forEach(function (r) {
+ var c = r.value.content
+ if (c.type === 'vote') {
+ if (c.vote.value === 1)
+ upvoters[r.value.author] = 1
+ else
+ delete upvoters[r.value.author]
+ }
+ else if (c.type == 'flag') {
+ if (c.flag && c.flag.reason)
+ flaggers[r.value.author] = c.flag.reason
+ else
+ delete flaggers[r.value.author]
+ }
+ else if (c.type == 'post') {
+ if (!isaReplyTo(r, msg) && isaMentionOf(r, msg))
+ mentions.push(r)
+ }
+ })
+
+ // update vote ui
+ if (upvoters[app.user.id])
+ updateFavBtn(el.querySelector('.message-header .favorite a'), true)
+ upvoters = Object.keys(upvoters)
+ var nupvoters = upvoters.length
+
+ var favusers = el.querySelector('.message-header .favorite .users')
+ favusers.innerHTML = ''
+ upvoters.slice(0, 5).forEach(function (id) {
+ var userimg = com.userImg(id)
+ favusers.appendChild(userimg)
+ })
+ if (nupvoters > 5)
+ favusers.appendChild(h('span', '+', nupvoters-5))
+
+ // handle flags
+ el.classList.remove('flagged-nsfw', 'flagged-spam', 'flagged-abuse')
+ for (var k in flaggers) {
+ // use the flag if we dont follow the author, or if we follow the flagger
+ // (that is, dont use flags by strangers on people we follow)
+ if (k == app.user.id || !social.follows(app.user.id, msg.value.author) || social.follows(app.user.id, k))
+ el.classList.add('flagged-'+flaggers[k])
+ }
+
+ // render mentions
+ if (mentions.length) {
+ el.querySelector('.message-inner').appendChild(h('.message-mentions', mentions.map(renderMention)))
+ }
+}
+
+function renderMention (m) {
+ var text = m.value.content.text
+ if (text.length > 40)
+ text = text.slice(0, 37) + '...'
+ if (text)
+ text = ': ' + text
+ return h('div', h('a', { href: '#/msg/'+m.key }, '↳ @', com.userName(m.value.author), text))
+}
+
+function isFlagUndone (r) {
+ if (r.related) {
+ return r.related.filter(function (msg) {
+ var c = msg.value.content
+ return (mlib.link(c.redacts) && mlib.link(c.redacts).link == r.key)
+ }).length > 0
+ }
+ return false
+}
diff --git a/ui/lib/com/nav.js b/ui/lib/com/nav.js
new file mode 100644
index 0000000..55d6a9f
--- /dev/null
+++ b/ui/lib/com/nav.js
@@ -0,0 +1,171 @@
+var h = require('hyperscript')
+var o = require('observable')
+var ssbref = require('ssb-ref')
+var com = require('./index')
+var ui = require('../ui')
+var modals = require('../ui/modals')
+var subwindows = require('../ui/subwindows')
+var app = require('../app')
+var u = require('../util')
+
+function addressbar () {
+
+ // markup
+
+ var addressInput = h('input', { value: '', onfocus: onfocus, onkeyup: onkeyup })
+
+ // handlers
+
+ function onfocus (e) {
+ setTimeout(function () { // shameless setTimeout to wait for default behavior (preventDefault doesnt seem to stop it)
+ addressInput.select() // select all on focus
+ }, 50)
+ }
+ function onkeyup (e) {
+ var v = addressInput.value
+ if (e.keyCode == 13 && v) {
+ if (v.charAt(0) == '@' && v.indexOf('.ed25519') !== -1)
+ window.location.hash = '#/profile/'+v
+ else if (v.charAt(0) == '&')
+ window.location.hash = '#/webview/'+v
+ else if (v.charAt(0) == '%')
+ window.location.hash = '#/msg/'+v
+ else
+ window.location.hash = '#/search/'+v
+ }
+ }
+
+ return [
+ addressInput,
+ h('.address-icon', com.icon('search'))
+ ]
+}
+
+exports.pagenav = function () {
+
+ // markup
+
+ // dropdowns
+ function onmenuclick (e) {
+ e.preventDefault()
+ e.stopPropagation()
+
+ // toggle warning sign on network sync
+ var syncWarningIcon
+ if (app.observ.hasSyncIssue())
+ syncWarningIcon = com.icon('warning-sign.text-danger')
+
+ ui.dropdown(this, [
+ h('a.item', { onclick: subwindows.pm, title: 'Compose an encrypted message' }, com.icon('lock'), ' Secret Message'),
+ h('a.item', { href: '#/inbox', title: 'Your inbox' }, com.icon('inbox'), ' Inbox (', app.observ.indexCounts.inboxUnread, ')'),
+ h('a.item', { href: '#/profile/'+app.user.id, title: 'View your site' }, com.icon('user'), ' Your Profile'),
+ h('hr'),
+ h('a.item', { href: '#/sync', title: 'Review the status of your network connections' }, com.icon('circle-arrow-down'), ' Network Sync ', syncWarningIcon),
+ h('a.item', { onclick: modals.invite, title: 'Connect to a public node using an invite code' }, com.icon('cloud'), ' Join a Public Node'),
+ h('hr'),
+ h('a.item', { href: '#/feed', title: 'View the raw data feed' }, com.icon('th-list'), ' Behind the Scenes'),
+ h('a.item.noicon', { href: 'https://github.com/ssbc/patchwork/issues/new', target: '_blank', title: 'File a suggestion or issue' }, com.icon('bullhorn'), ' File an Issue')
+ ], { right: true, offsetY: 5 })
+ }
+
+ function onsideview () {
+ app.observ.sideview(!app.observ.sideview())
+ }
+ var sideviewBtn = o.transform(app.observ.sideview, function (b) {
+ var enabled = (app.page.id == 'profile' && ssbref.isFeedId(app.page.param))
+ return h('a.button'+(enabled?'':'.disabled'),
+ { onclick: ((enabled) ? onsideview : null), title: 'Toggle the about panel' },
+ com.icon(b&&enabled ? 'collapse-down' : 'collapse-up')
+ )
+ })
+
+ // toggle warning sign on network sync
+ var networkSync = o.transform(app.observ.hasSyncIssue, function (b) {
+ if (b)
+ return h('a.button', { href: '#/sync', title: 'Warning! You are not online' }, com.icon('warning-sign.text-danger'))
+ return h('a.button', { href: '#/sync', title: 'Review the status of your network connections' }, com.icon('circle-arrow-down'))
+ })
+
+ // render nav
+ return h('.page-nav-inner',
+ h('a.button.home', { href: '#/', title: 'Home page launcher' }, com.icon('home')),
+ h('a.button', { onclick: ui.navBack, title: 'Go back' }, com.icon('arrow-left')),
+ h('a.button', { onclick: ui.navForward, title: 'Go forward' }, com.icon('arrow-right')),
+ h('a.button', { onclick: ui.navRefresh, title: 'Refresh this page' }, com.icon('refresh')),
+ addressbar(),
+ // sideviewBtn,
+ networkSync,
+ h('a.button', { onclick: onmenuclick }, com.icon('menu-hamburger'))
+ )
+
+ function item (id, path, label, extra_cls) {
+ var selected = (id == app.page.id) ? '.selected' : ''
+ return h('a.pagenav-'+id+(extra_cls||'')+selected, { href: '#/'+path }, label)
+ }
+}
+
+exports.sidenav = function (opts) {
+ function onviewclick (view) {
+ return function (e) {
+ e.preventDefault()
+ app.homeMode.view = view
+ ui.refreshPage()
+ }
+ }
+ function view (view, label) {
+ if (app.homeMode.view == view)
+ return h('li.view', h('strong', h('a', { href: '#', onclick: onviewclick(view) }, label)))
+ return h('li.view', h('a', { href: '#', onclick: onviewclick(view) }, label))
+ }
+
+ function onoptionsclick (e) {
+ e.preventDefault()
+ e.stopPropagation()
+ function label(b, l) {
+ return [com.icon(b ? 'check' : 'unchecked'), l]
+ }
+ ui.dropdown(e.target, [
+ { value: 'live', label: label(app.homeMode.live, ' Livestream'), title: 'Show new updates to the feed in realtime' },
+ { value: 'nsfw', label: label(app.filters.nsfw, ' NSFW Filter'), title: 'Show/hide posts flagged as NSFW by people you follow' },
+ { value: 'spam', label: label(app.filters.spam, ' Spam Filter'), title: 'Show/hide posts flagged as Spam by people you follow' },
+ { value: 'abuse', label: label(app.filters.abuse, ' Abuse Filter'), title: 'Show/hide posts flagged as Abuse by people you follow' }
+ ], function (choice) {
+ if (choice == 'live') {
+ app.homeMode.live = !app.homeMode.live
+ ui.refreshPage()
+ } else {
+ var hide = !app.filters[choice]
+ app.filters[choice] = hide
+ if (!hide)
+ document.querySelector('.message-feed').classList.add('show-'+choice)
+ else
+ document.querySelector('.message-feed').classList.remove('show-'+choice)
+ }
+ })
+ }
+
+ return h('ul.list-unstyled.sidenav',
+ h('li', h('h4', 'feed ', h('a', { href: '#', onclick: onoptionsclick, title: 'Options for this feed view', style: 'font-size: 12px; color: gray;' }, 'options'))),
+ view('all', ['all', h('small', ' users on your network')]),
+ view('friends', ['friends', h('small', ' that you have followed')]),
+ o.transform(app.observ.peers, function (peers) {
+ // :HACK: hyperscript needs us to return an Element if it's going to render
+ // we really shouldnt be returning a div here, but it does render correctly
+ // would be better to update hyperscript to correctly handle an array
+ return h('div', peers
+ .sort(function (a, b) {
+ if (!a.announcers) return -1
+ if (!b.announcers) return 1
+ return (a.announcers.length - b.announcers.length)
+ })
+ .map(function (peer) {
+ if (!peer.time || !peer.time.connect) return
+ return view(peer.key, [peer.host, h('small', ' members')])
+ })
+ .filter(Boolean)
+ )
+ }),
+ h('li', h('br')),
+ h('li', h('h4', 'follows and flags'))
+ )
+}
\ No newline at end of file
diff --git a/ui/lib/com/network-graph.js b/ui/lib/com/network-graph.js
new file mode 100644
index 0000000..4ef0b37
--- /dev/null
+++ b/ui/lib/com/network-graph.js
@@ -0,0 +1,104 @@
+var h = require('hyperscript')
+var app = require('../app')
+var com = require('./index')
+
+if ('sigma' in window) {
+ sigma.canvas.nodes.square = function (node, context, settings) {
+ var prefix = settings('prefix') || '',
+ size = node[prefix + 'size']
+
+ context.strokeStyle = node.color || settings('defaultNodeColor')
+ context.fillStyle = 'rgba(238,238,238,0.7)'
+ context.beginPath()
+ context.rect(
+ node[prefix + 'x'] - size,
+ node[prefix + 'y'] - size,
+ size * 2,
+ size * 2
+ )
+
+ context.closePath()
+ context.fill()
+ context.stroke()
+ }
+}
+
+module.exports = function (opts) {
+ var container = h('.network-graph')
+ opts = opts || {}
+ opts.w = opts.w || 3
+ opts.h = opts.h || 1
+ app.ssb.friends.all(function (err, friends) {
+
+ // generate graph
+ var graph = { nodes: [], edges: [] }
+ for (var id in friends) {
+ // add node
+ var inbounds = countInbounds(friends, id)
+ var xr = Math.random()
+ var yr = Math.random()
+ if (xr > 0.45 && xr <= 0.5) xr -= 0.1
+ if (yr > 0.45 && yr <= 0.5) yr -= 0.1
+ if (xr < 0.55 && xr >= 0.5) xr += 0.1
+ if (yr < 0.55 && yr >= 0.5) yr += 0.1
+ graph.nodes.push({
+ id: id,
+ type: 'square',
+ label: com.userName(id),
+ x: (id == app.user.id) ? 1.5 : xr * opts.w,
+ y: (id == app.user.id) ? 0.5 : yr * opts.h,
+ size: inbounds+1,
+ color: (id == app.user.id) ? '#970' : (friends[app.user.id] && friends[app.user.id][id] ? '#790' : (friends[id][app.user.id] ? '#00c' : '#666'))
+ })
+
+ // show edges related to current user
+ if (id == app.user.id) {
+ // outbound
+ for (var id2 in friends[id]) {
+ graph.edges.push({
+ id: id+'->'+id2,
+ source: id,
+ target: id2,
+ size: 0.1,
+ color: '#9a3'
+ })
+ }
+ } else {
+ // inbound
+ if (friends[id][app.user.id]) {
+ graph.edges.push({
+ id: id+'->'+app.user.id,
+ source: id,
+ target: app.user.id,
+ size: 0.1,
+ color: '#97a'
+ })
+ }
+ }
+ }
+
+ // empty graph?
+ if (graph.edges.length === 0) {
+ // how embarrassing, plz hide it
+ container.style.height = '1px'
+ return
+ }
+
+ // render
+ var s = new sigma({
+ graph: graph,
+ renderer: { container: container, type: 'canvas' },
+ settings: opts
+ })
+ })
+ return container
+}
+
+function countInbounds (graph, id) {
+ var n=0
+ for (var id2 in graph) {
+ if (id in graph[id2])
+ n++
+ }
+ return n
+}
\ No newline at end of file
diff --git a/ui/lib/com/notifications.js b/ui/lib/com/notifications.js
new file mode 100644
index 0000000..98e6503
--- /dev/null
+++ b/ui/lib/com/notifications.js
@@ -0,0 +1,30 @@
+var h = require('hyperscript')
+var o = require('observable')
+var app = require('../app')
+var com = require('./index')
+
+module.exports = function () {
+
+ // markup
+
+ var notes = []
+ for (var k in app.actionItems) {
+ var item = app.actionItems[k]
+ if (item.type == 'name-conflict') {
+ notes.push(h('.note.warning',
+ h('h3', 'Heads up!'),
+ h('p', 'You are following more than one user named "'+item.name+'." You need to rename one of them to avoid confusion.'),
+ h('ul.list-inline', item.ids.map(function (id) { return h('li', com.userImg(id), ' ', com.user(id)) }))
+ ))
+ }
+ }
+
+ return (notes.length) ? h('.notifications', notes) : null
+}
+
+module.exports.side = function () {
+ return o.transform(app.observ.hasSyncIssue, function (b) {
+ if (!b) return ''
+ return h('.well', { style: 'margin-top: 5px' }, h('a.text-muted', { href: '#/sync' }, com.icon('warning-sign'), ' You\'re not connected to the public mesh.'))
+ })
+}
\ No newline at end of file
diff --git a/ui/lib/com/peers.js b/ui/lib/com/peers.js
new file mode 100644
index 0000000..688acf9
--- /dev/null
+++ b/ui/lib/com/peers.js
@@ -0,0 +1,87 @@
+'use strict'
+var h = require('hyperscript')
+var app = require('../app')
+var com = require('./index')
+var util = require('../util')
+
+module.exports = function (peers) {
+
+ // markup
+
+ var rows = peers.sort(sorter).map(function (peer) {
+ var muted = (peer.connected) ? '' : '.text-muted'
+ var id = '', status = '', history = ''
+
+ if (peer.id) {
+ id = com.userlink(peer.id, app.users.names[peer.id])
+ } else
+ id = peer.host
+
+ if (peer.connected) {
+ if (peer.time && peer.time.connect)
+ status = 'connected'
+ else {
+ if (peer.failure)
+ status = 'connecting (try '+(peer.failure+1)+')...'
+ else
+ status = 'connecting...'
+ }
+ }
+
+ if (peer.time) {
+ if (peer.time.connect > peer.time.attempt)
+ history = 'connected '+util.prettydate(peer.time.connect, true)
+ else if (peer.time.attempt) {
+ if (peer.connected)
+ history = 'started attempt '+util.prettydate(peer.time.attempt, true)
+ else
+ history = 'attempted connect '+util.prettydate(peer.time.attempt, true)
+ }
+ }
+
+ return h('tr',
+ h('td'+muted,
+ id,
+ (peer.connected) ? ' '+status : h('a.btn.btn-xs.btn-default', { href: '#', title: 'Syncronize now', onclick: syncronize(peer) }, com.icon('transfer')),
+ h('br'),
+ h('small.text-muted', history)
+ )
+ )
+ })
+
+ if (rows.length === 0)
+ rows.push(h('tr', h('td.text-muted', 'No known peers')))
+
+ // put connected peers at top
+ function sorter(a, b) {
+ var an = 0, bn = 0
+ if (a.connected) an += 100
+ if (b.connected) bn += 100
+ if (a.failure) an -= a.failure
+ if (b.failure) bn -= b.failure
+ return bn - an
+ }
+
+ // handlers
+
+ function syncronize (p) {
+ return function (e) {
+ e.preventDefault()
+ app.ssb.gossip.connect(p, function (err) {
+ if (err)
+ return console.error(err)
+
+ var node = e.target
+ var parent = node.parentNode
+ if (parent.tagName == 'A') {
+ node = parent
+ parent = node.parentNode
+ }
+ parent.insertBefore(h('span', ' connecting...'), node)
+ parent.removeChild(node)
+ })
+ }
+ }
+
+ return rows
+}
\ No newline at end of file
diff --git a/ui/lib/com/pm-form.js b/ui/lib/com/pm-form.js
new file mode 100644
index 0000000..1c9d225
--- /dev/null
+++ b/ui/lib/com/pm-form.js
@@ -0,0 +1,201 @@
+'use strict'
+var h = require('hyperscript')
+var suggestBox = require('suggest-box')
+var schemas = require('ssb-msg-schemas')
+var refs = require('ssb-ref')
+var createHash = require('multiblob/util').createHash
+var pull = require('pull-stream')
+var pushable = require('pull-pushable')
+var app = require('../app')
+var ui = require('../ui')
+var modals = require('../ui/modals')
+var com = require('./index')
+var util = require('../util')
+var markdown = require('../markdown')
+var mentionslib = require('../mentions')
+var social = require('../social-graph')
+
+module.exports = function (opts) {
+
+ var recipients = []
+ var placeholder = (opts && opts.placeholder) ? opts.placeholder : ''
+
+ // make sure there are no name conflicts first
+ var conflicts = []
+ for (var k in app.actionItems) {
+ var item = app.actionItems[k]
+ if (item.type == 'name-conflict') {
+ conflicts.push(h('.note.warning',
+ h('h3', 'Heads up!'),
+ h('p', 'You are following more than one user named "'+item.name+'." You need to rename one of them before you send secret messages, to avoid confusion.'),
+ h('ul.list-inline', item.ids.map(function (id) { return h('li', com.userImg(id), ' ', com.user(id)) }))
+ ))
+ }
+ }
+ if (conflicts.length)
+ return h('.notifications', { style: 'margin-top: 24px' }, conflicts)
+
+ // markup
+
+ var recpInput = h('input', { onsuggestselect: onSelectRecipient, onkeydown: onRecpInputKeydown })
+ var recipientsEl = h('.pm-form-recipients', h('span.recp-label', 'To'), recpInput)
+ var textarea = h('textarea', { name: 'text', placeholder: placeholder, onkeyup: onTextChange })
+ var postBtn = h('button.postbtn.btn', { disabled: true }, 'Send')
+ suggestBox(textarea, app.suggestOptions)
+ suggestBox(recpInput, { any: app.suggestOptions['@'] }, { cls: 'msg-recipients' })
+ renderRecpList()
+
+ if (opts && opts.recipients)
+ opts.recipients.forEach(addRecp)
+
+ var form = h('form.pm-form', { onsubmit: post },
+ recipientsEl,
+ h('.pm-form-textarea', textarea),
+ h('.pm-form-attachments', postBtn)
+ )
+
+ function disable () {
+ postBtn.setAttribute('disabled', true)
+ }
+
+ function enable () {
+ postBtn.removeAttribute('disabled')
+ }
+
+ function renderRecpList () {
+ // remove all .recp
+ Array.prototype.forEach.call(recipientsEl.querySelectorAll('.recp'), function (el) {
+ recipientsEl.removeChild(el)
+ })
+
+ // render
+ recipients.forEach(function (id) {
+ recipientsEl.insertBefore(h('.recp',
+ com.icon('lock'),
+ ' ',
+ com.userName(id),
+ ' ',
+ h('a', { href: '#', onclick: onRemoveRecipient, 'data-id': id, innerHTML: '×', tabIndex: '-1' })
+ ), recpInput)
+ })
+
+ resizeTextarea()
+ }
+
+ // handlers
+
+ function onTextChange (e) {
+ if (recipients.length && textarea.value.trim())
+ enable()
+ else
+ disable()
+ }
+
+ function addRecp (id) {
+ // enforce limit
+ if (recipients.length >= 7) {
+ ui.notice('warning', 'Cannot add @'+com.userName(id)+' - You have reached the limit of 7 recipients on a Secret Message.')
+ recpInput.value = ''
+ return
+ }
+
+ // warn if the recipient doesnt follow the current user
+ if (id !== app.user.id && !social.follows(id, app.user.id))
+ ui.notice('warning', 'Warning: @'+com.userName(id)+' does not follow you, and may not receive your message.')
+
+ // remove if already exists (we'll push to end of list so user sees its there)
+ var i = recipients.indexOf(id)
+ if (i !== -1)
+ recipients.splice(i, 1)
+
+ // add, render
+ recipients.push(id)
+ recpInput.value = ''
+ renderRecpList()
+ }
+
+ function onSelectRecipient (e) {
+ addRecp(e.detail.id)
+ }
+
+ function onRemoveRecipient (e) {
+ e.preventDefault()
+ var i = recipients.indexOf(e.target.dataset.id)
+ if (i !== -1) {
+ recipients.splice(i, 1)
+ renderRecpList()
+ recpInput.focus()
+ }
+ }
+
+ function onRecpInputKeydown (e) {
+ // backspace on an empty field?
+ if (e.keyCode == 8 && recpInput.value == '' && recipients.length) {
+ recipients.pop()
+ renderRecpList()
+ }
+ }
+
+ // dynamically sizes the textarea based on available space
+ // (no css method, including flexbox, would really nail this one)
+ function resizeTextarea () {
+ try {
+ var height = 400 - 4
+ height -= recipientsEl.getClientRects()[0].height
+ height -= form.querySelector('.pm-form-attachments').getClientRects()[0].height
+ textarea.style.height = height + 'px'
+ } catch (e) {
+ // ignore, probably havent rendered yet
+ }
+ }
+
+ function post (e) {
+ e.preventDefault()
+
+ var text = textarea.value
+ if (!text.trim())
+ return
+
+ disable()
+ ui.pleaseWait(true)
+
+ // prep text
+ mentionslib.extract(text, function (err, mentions) {
+ if (err) {
+ ui.setStatus(null)
+ ui.pleaseWait(false)
+ enable()
+ if (err.conflict)
+ modals.error('Error While Publishing', 'You follow multiple people with the name "'+err.name+'." Go to the homepage to resolve this before publishing.')
+ else
+ modals.error('Error While Publishing', err, 'This error occured while trying to extract the mentions from a secret message text.')
+ return
+ }
+
+ // make sure the user is in the recipients
+ if (recipients.indexOf(app.user.id) === -1)
+ recipients.push(app.user.id)
+
+ // list recipients with their names
+ var recps = recipients.map(function (id) {
+ return { link: id, name: com.userName(id) }
+ })
+
+ // publish
+ var post = schemas.post(text, null, null, mentions, recps)
+ app.ssb.private.publish(post, recipients, function (err, msg) {
+ ui.setStatus(null)
+ enable()
+ ui.pleaseWait(false)
+ if (err) modals.error('Error While Publishing', err, 'This error occured while trying to private-publish a new secret message.')
+ else {
+ app.ssb.patchwork.subscribe(msg.key)
+ app.ssb.patchwork.markRead(msg.key)
+ opts && opts.onpost && opts.onpost(msg)
+ }
+ })
+ })
+ }
+
+ return form
+}
\ No newline at end of file
diff --git a/ui/lib/com/post-form.js b/ui/lib/com/post-form.js
new file mode 100644
index 0000000..643f1a6
--- /dev/null
+++ b/ui/lib/com/post-form.js
@@ -0,0 +1,185 @@
+'use strict'
+var h = require('hyperscript')
+var suggestBox = require('suggest-box')
+var schemas = require('ssb-msg-schemas')
+var mlib = require('ssb-msgs')
+var app = require('../app')
+var ui = require('../ui')
+var modals = require('../ui/modals')
+var com = require('./index')
+var markdown = require('../markdown')
+var mentionslib = require('../mentions')
+var social = require('../social-graph')
+
+module.exports = function (rootMsg, branchMsg, opts) {
+
+ var isSecret = (rootMsg && rootMsg.plaintext === false)
+ var namesList = {} // a name->id map for the previews
+ for (var id in app.users.names)
+ if (id == app.user.id || social.follows(app.user.id, id))
+ namesList[app.users.names[id]] = id
+ var placeholder = (opts && opts.placeholder) ?
+ opts.placeholder :
+ (!rootMsg ? 'Share a message with the world...' : 'Reply...')
+
+ // markup
+
+ var previewEl = h('.post-form-preview')
+ var filesInput = h('input.hidden', { type: 'file', multiple: true, onchange: filesAdded })
+ var textarea = h('textarea.short', {
+ name: 'text',
+ placeholder: placeholder,
+ value: (opts && opts.initval) ? opts.initval : '',
+ rows: ((opts && opts.rows) ? opts.rows : 1),
+ onkeyup: onPostTextChange
+ })
+ var postBtn = h('button.postbtn.btn', 'Publish')
+ suggestBox(textarea, app.suggestOptions)
+
+ var form = h('form.post-form' + ((!!rootMsg) ? '.reply-form' : ''), { onsubmit: post },
+ (!opts || !opts.noheader) ? h('small.text-muted', 'Public post. Markdown, @-mentions, and emojis are supported. ', h('a', { href: '#', onclick: cancel }, 'Cancel')) : '',
+ h('.post-form-textarea', textarea),
+ previewEl,
+ h('.post-form-attachments.hidden',
+ postBtn,
+ (!isSecret) ? h('a', { href: '#', onclick: addFile }, 'Click here to add an attachment') : '',
+ filesInput
+ )
+ )
+
+ function disable () {
+ form.querySelector('.post-form-attachments').classList.add('hidden')
+ textarea.setAttribute('rows', 1)
+ textarea.classList.add('short')
+ }
+
+ function enable () {
+ form.querySelector('.post-form-attachments').classList.remove('hidden')
+ textarea.setAttribute('rows', 4)
+ textarea.classList.remove('short')
+ }
+
+ // handlers
+
+ function onPostTextChange () {
+ previewEl.innerHTML = (!!textarea.value) ? markdown.block(textarea.value, namesList) : ''
+ if (textarea.value.trim())
+ enable()
+ else
+ disable()
+ }
+
+ function post (e) {
+ e.preventDefault()
+
+ var text = textarea.value
+ if (!text.trim())
+ return
+
+ disable()
+ ui.pleaseWait(true)
+
+ // abort if the rootMsg wasnt decryptable
+ if (rootMsg && typeof rootMsg.value.content == 'string') {
+ ui.pleaseWait(false)
+ ui.notice('danger', 'Unable to decrypt rootMsg message')
+ enable()
+ return
+ }
+
+ // prep text
+ mentionslib.extract(text, function (err, mentions) {
+ if (err) {
+ ui.setStatus(null)
+ ui.pleaseWait(false)
+ enable()
+ if (err.conflict)
+ modals.error('Error While Publishing', 'You follow multiple people with the name "'+err.name+'." Go to the homepage to resolve this before publishing.')
+ else
+ modals.error('Error While Publishing', err, 'This error occured while trying to extract the mentions from a new post.')
+ return
+ }
+
+ // get encryption recipients from rootMsg
+ var recps
+ try {
+ if (Array.isArray(rootMsg.value.content.recps)) {
+ recps = mlib.links(rootMsg.value.content.recps)
+ .map(function (recp) { return recp.link })
+ .filter(Boolean)
+ }
+ } catch (e) {}
+
+ // post
+ var post = schemas.post(text, rootMsg && rootMsg.key, branchMsg && branchMsg.key, mentions, recps)
+ if (recps)
+ app.ssb.private.publish(post, recps, published)
+ else
+ app.ssb.publish(post, published)
+
+ function published (err, msg) {
+ ui.setStatus(null)
+ enable()
+ ui.pleaseWait(false)
+ if (err) modals.error('Error While Publishing', err, 'This error occurred while trying to publish a new post.')
+ else {
+ textarea.value = ''
+ onPostTextChange()
+ app.ssb.patchwork.subscribe(msg.key)
+ app.ssb.patchwork.markRead(msg.key)
+ opts && opts.onpost && opts.onpost(msg)
+ }
+ }
+ })
+ }
+
+ function cancel (e) {
+ e.preventDefault()
+
+ if (textarea.value && !confirm('Are you sure you want to cancel? Your message will be lost.'))
+ return
+
+ form.parentNode.removeChild(form)
+ opts && opts.oncancel && opts.oncancel()
+ }
+
+ function addFile (e) {
+ e.preventDefault()
+ filesInput.click() // trigger file-selector
+ }
+
+ function filesAdded (e) {
+ // hash the files
+ var n = filesInput.files.length
+ ui.setStatus('Hashing ('+n+' files left)...')
+ for (var i=0; i < n; i++) {
+ if (!add(filesInput.files[i])) {
+ ui.setStatus(false)
+ return
+ }
+ }
+
+ function add (f) {
+ if (f.size > 5 * (1024*1024)) {
+ var inMB = Math.round(f.size / (1024*1024) * 100) / 100
+ modals.error('Error Attaching File', f.name + ' is larger than the 5 megabyte limit (' + inMB + ' MB)')
+ return false
+ }
+ app.ssb.patchwork.addFileToBlobs(f.path, function (err, res) {
+ if (err) {
+ modals.error('Error Attaching File', error, 'This error occurred while trying to add a file to the blobstore for a new post.')
+ } else {
+ if (!(/(^|\s)$/.test(textarea.value)))
+ textarea.value += ' '
+ textarea.value += '['+(f.name||'untitled')+']('+res.hash+')'
+ onPostTextChange()
+ if (--n === 0)
+ ui.setStatus(false)
+ }
+ })
+ return true
+ }
+ }
+
+ return form
+}
diff --git a/ui/lib/com/pretty-raw.js b/ui/lib/com/pretty-raw.js
new file mode 100644
index 0000000..3d14c9c
--- /dev/null
+++ b/ui/lib/com/pretty-raw.js
@@ -0,0 +1,92 @@
+var h = require('hyperscript')
+var ssbref = require('ssb-ref')
+var com = require('./index')
+var u = require('../util')
+
+function file (link, rel) {
+ var name = link.name || rel
+ var details = (('size' in link) ? u.bytesHuman(link.size) : '') + ' ' + (link.type||'')
+ return h('a', { href: '/ext/'+link.ext, target: '_blank', title: name +' '+details }, name, ' ', h('small', details))
+}
+
+function message (link, rel) {
+ if (typeof rel == 'string')
+ return h('a', { href: '#/msg/'+link.msg, innerHTML: u.escapePlain(rel)+' »' })
+}
+
+var prettyRaw =
+module.exports = function (obj, path) {
+ if (typeof obj == 'string')
+ return h('span.pretty-raw', h('em', 'Encrypted message'))
+
+ function col (k, v) {
+ k = (k) ? path+k : ''
+ return h('span.pretty-raw', h('small', k), v)
+ }
+
+ var els = []
+ path = (path) ? path + '.' : ''
+ for (var k in obj) {
+ if (obj[k] && typeof obj[k] == 'object') {
+ // :TODO: render links
+ // if (obj[k].ext)
+ // els.push(col('', file(obj[k])))
+ // if (obj[k].msg)
+ // els.push(col('', message(obj[k])))
+ // if (obj[k].feed)
+ // els.push(col(k, com.user(obj[k].feed)))
+ els = els.concat(prettyRaw(obj[k], path+k))
+ }
+ else
+ els.push(col(k, ''+obj[k]))
+ }
+
+ return els
+}
+
+var prettyRawTable =
+module.exports.table = function (obj, path) {
+ if (typeof obj == 'string') {
+ var el = h('tr.pretty-raw', h('td'), h('td.text-muted', 'Encrypted message'))
+
+ // try to decrypt
+ app.ssb.private.unbox(obj, function (err, decrypted) {
+ if (decrypted) {
+ var rows = prettyRawTable(decrypted)
+ if (el.parentNode) {
+ rows.forEach(function (row) {
+ el.parentNode.appendChild(row)
+ })
+ }
+ }
+ })
+
+ return el
+ }
+
+ function row (k, v) {
+ if (typeof v === 'boolean')
+ v = com.icon(v ? 'ok' : 'remove')
+ return h('tr.pretty-raw', h('td', path+k), h('td', v))
+ }
+
+ var els = []
+ path = (path) ? path + '.' : ''
+ for (var k in obj) {
+ if (obj[k] && typeof obj[k] == 'object') {
+ els = els.concat(prettyRawTable(obj[k], path+k))
+ } else if (ssbref.isLink(obj[k])) {
+ var ref = obj[k]
+ if (ssbref.isMsgId(ref))
+ els.push(row(k, com.a('#/msg/'+ref, ref)))
+ else if (ssbref.isBlobId(ref))
+ els.push(row(k, com.a('#/webview/'+ref, obj.name || ref)))
+ else
+ els.push(row(k, com.user(ref)))
+ } else
+ els.push(row(k, obj[k]))
+
+ }
+
+ return els
+}
\ No newline at end of file
diff --git a/ui/lib/com/rename-form.js b/ui/lib/com/rename-form.js
new file mode 100644
index 0000000..8b438f4
--- /dev/null
+++ b/ui/lib/com/rename-form.js
@@ -0,0 +1,19 @@
+var h = require('hyperscript')
+var com = require('./index')
+
+module.exports = function (id, opts) {
+
+ // markup
+
+ var oldname = com.userName(id)
+ var nameinput = h('input.form-control', { value: oldname })
+ var form = h('.rename-form',
+ h('h3', 'Rename "', oldname, '"'),
+ h('p.text-muted', h('small', 'You can rename anybody! Other people can see the name you choose, but it will only affect you.')),
+ h('form.form-inline', { onsubmit: function (e) { e.preventDefault(); opts.onsubmit(nameinput.value) } },
+ h('p', nameinput, h('button.btn.btn-3d', 'Save'))
+ )
+ )
+
+ return form
+}
\ No newline at end of file
diff --git a/ui/lib/com/user-downloader.js b/ui/lib/com/user-downloader.js
new file mode 100644
index 0000000..004e36e
--- /dev/null
+++ b/ui/lib/com/user-downloader.js
@@ -0,0 +1,41 @@
+'use strict'
+var h = require('hyperscript')
+var pull = require('pull-stream')
+var app = require('../app')
+var modals = require('../ui/modals')
+var ui = require('../ui')
+
+module.exports = function (id) {
+ var success = false, errored = false
+ var btn = h('a.btn.btn-primary', { onclick: onclick }, 'Download User')
+
+ function onclick () {
+ btn.classList.add('disabled')
+ btn.innerText = 'Searching...'
+ pull(app.ssb.patchwork.useLookupCode(id), pull.drain(onLookupEvent, onLookupDone))
+ }
+
+ function onLookupEvent (e) {
+ if (e.type == 'error' && e.message == 'Invalid lookup code')
+ errored = true, modals.error('Error Downloading User', 'This is not a valid user ID: '+id, 'This error occurred while trying to download a user that wasn\'t locally available.')
+ else if (e.type == 'connecting')
+ btn.innerText = 'Asking '+e.addr.host
+ else if (e.type == 'finished') {
+ btn.innerText = 'Success!'
+ success = true
+ }
+ }
+
+ function onLookupDone () {
+ btn.classList.remove('disabled')
+ if (success)
+ ui.refreshPage()
+ else {
+ if (!errored)
+ modals.error('User Not Found', 'None of the available mesh nodes had this user\'s data. Make sure you\'re online, and that you have the right ID, then try again.', 'Attempted ID: '+id)
+ btn.innerText = 'Try Download Again'
+ }
+ }
+
+ return btn
+}
\ No newline at end of file
diff --git a/ui/lib/com/webcam-giffer-form.js b/ui/lib/com/webcam-giffer-form.js
new file mode 100644
index 0000000..1f853c8
--- /dev/null
+++ b/ui/lib/com/webcam-giffer-form.js
@@ -0,0 +1,259 @@
+'use strict'
+var h = require('hyperscript')
+var o = require('observable')
+var schemas = require('ssb-msg-schemas')
+var mlib = require('ssb-msgs')
+var pull = require('pull-stream')
+var pushable = require('pull-pushable')
+var createHash = require('multiblob/util').createHash
+var suggestBox = require('suggest-box')
+var toBuffer = require('blob-to-buffer')
+var app = require('../app')
+var ui = require('../ui')
+var modals = require('../ui/modals')
+var com = require('./index')
+var mentionslib = require('../mentions')
+
+var videoOpts = {
+ optional: [
+ { minHeight: 150 },
+ { maxHeight: 150 },
+ { minWidth: 300 },
+ { maxWidth: 300 }
+ ]
+}
+
+module.exports = function (rootMsg, branchMsg, opts) {
+ opts = opts || {}
+
+ var blob
+ var recordInterval
+ var encoder = new Whammy.Video(10)
+ var countdown = o(0)
+
+ // markup
+
+ var canvas = h('canvas')
+ var context = canvas.getContext('2d')
+ var invideo = h('video')
+ var outvideo = h('video.hide', { autoplay: true, loop: true })
+ var textarea = h('textarea.form-control', {
+ name: 'text',
+ placeholder: 'Add a message (optional)',
+ rows: 6
+ })
+ var publishBtn = h('button.btn.btn-primary.pull-right.hidden', { onclick: onpublish }, 'Publish')
+ var form = h('form.webcam-giffer-form',
+ h('.webcam-giffer-form-videos', { onmousedown: onmousedown },
+ o.transform(countdown, function (c) {
+ if (!c)
+ return ''
+ return h('.countdown', c)
+ }),
+ invideo,
+ outvideo,
+ h('br'),
+ h('a.btn.btn-3d', { onclick: onrecord(1) }, com.icon('record'), ' Record 1s'), ' ',
+ h('a.btn.btn-3d', { onclick: onrecord(2) }, '2s'), ' ',
+ h('a.btn.btn-3d', { onclick: onrecord(3), style: 'margin-right: 10px' }, '3s'),
+ h('a.text-muted', { href: '#', onclick: onreset }, com.icon('repeat'), ' Reset')
+ ),
+ h('.webcam-giffer-form-ctrls', textarea, publishBtn)
+ )
+ suggestBox(textarea, app.suggestOptions)
+
+ function disable () {
+ publishBtn.classList.add('hidden')
+ }
+
+ function enable () {
+ publishBtn.classList.remove('hidden')
+ }
+
+ // handlers
+
+ function onmousedown (e) {
+ if (e.target.tagName == 'VIDEO') {
+ e.preventDefault()
+ startRecording()
+ document.addEventListener('mouseup', onmouseup)
+ }
+ }
+ function onmouseup (e) {
+ e.preventDefault()
+ stopRecording()
+ document.removeEventListener('mouseup', onmouseup)
+ }
+ function onrecord (seconds) {
+ return function (e) {
+ e.preventDefault()
+ startRecordingAfter(2, seconds)
+ }
+ }
+ function onreset (e) {
+ e && e.preventDefault()
+ encoder.frames = []
+ invideo.classList.remove('hide')
+ outvideo.classList.add('hide')
+ disable()
+ }
+ function onpublish (e) {
+ e.preventDefault()
+
+ var text = textarea.value || ''
+ if (!blob)
+ return
+
+ disable()
+ ui.pleaseWait(true)
+
+ // abort if the rootMsg wasnt decryptable
+ if (rootMsg && typeof rootMsg.value.content == 'string') {
+ ui.pleaseWait(false)
+ ui.notice('danger', 'Unable to decrypt rootMsg message')
+ enable()
+ return
+ }
+
+ function onerr (err) {
+ ui.setStatus(null)
+ ui.pleaseWait(false)
+ enable()
+ modals.error('Error While Publishing', err, 'This error occurred while trying to upload a webcam video to the blobstore.')
+ }
+
+ // upload blob to sbot
+ var hasher = createHash('sha256')
+ var ps = pushable()
+ pull(
+ ps,
+ hasher,
+ app.ssb.blobs.add(function (err) {
+ if (err) return onerr(err)
+ afterUpload()
+ })
+ )
+ toBuffer(blob, function (err, buffer) {
+ if (err) return onerr(err)
+ ps.push(buffer)
+ ps.end()
+ })
+
+ function afterUpload () {
+ // prepend the image-embed ot the text
+ text = '![webcam.webm](&'+hasher.digest+')\n\n' + text
+ console.log('posting', text)
+
+ // prep text
+ mentionslib.extract(text, function (err, mentions) {
+ if (err) {
+ ui.setStatus(null)
+ ui.pleaseWait(false)
+ enable()
+ if (err.conflict)
+ modals.error('Error While Publishing', 'You follow multiple people with the name "'+err.name+'." Go to the homepage to resolve this before publishing.')
+ else
+ modals.error('Error While Publishing', err, 'This error occurred while trying to extract the mentions from the text of a webcam post.')
+ return
+ }
+
+ // get encryption recipients from rootMsg
+ var recps
+ try {
+ if (Array.isArray(rootMsg.value.content.recps)) {
+ recps = mlib.links(rootMsg.value.content.recps)
+ .map(function (recp) { return recp.link })
+ .filter(Boolean)
+ }
+ } catch (e) {}
+
+ // post
+ var post = schemas.post(text, rootMsg && rootMsg.key, branchMsg && branchMsg.key, mentions, recps)
+ if (recps)
+ app.ssb.private.publish(post, recps, published)
+ else
+ app.ssb.publish(post, published)
+
+ function published (err, msg) {
+ ui.setStatus(null)
+ enable()
+ ui.pleaseWait(false)
+ if (err) modals.error('Error While Publishing', err, 'This error occurred while trying to post a webcam video.')
+ else {
+ textarea.value = ''
+ app.ssb.patchwork.subscribe(msg.key)
+ app.ssb.patchwork.markRead(msg.key)
+ opts && opts.onpost && opts.onpost(msg)
+ onreset()
+ }
+ }
+ })
+ }
+ }
+
+
+ // init webcam
+ navigator.webkitGetUserMedia({ video: videoOpts, audio: false }, function (stream) {
+ invideo.src = window.URL.createObjectURL(stream)
+ invideo.onloadedmetadata = function () { invideo.play() }
+ ui.onTeardown(function () {
+ stream.stop()
+ })
+ }, function (err) {
+ modals.error('Failed to Access Webcam', err)
+ })
+
+ // recording functions
+ function startRecordingAfter(c, seconds) {
+ // show input stream
+ invideo.classList.remove('hide')
+ outvideo.classList.add('hide')
+
+ // run countdown
+ countdown(c)
+ var i = setInterval(function () {
+ countdown(countdown() - 1)
+ if (countdown() === 0) {
+ clearInterval(i)
+ startRecording(seconds)
+ }
+ }, 1000)
+ }
+ function startRecording (seconds) {
+ // show input stream
+ invideo.classList.remove('hide')
+ outvideo.classList.add('hide')
+
+ // add 'recording' border
+ invideo.classList.add('recording')
+
+ // start capture
+ recordInterval = setInterval(captureFrame, 1000/10)
+ // captureFrame()
+ if (seconds)
+ setTimeout(stopRecording, seconds*1000)
+ }
+ function captureFrame () {
+ context.drawImage(invideo, 0, 0, 300, 150)
+ encoder.add(canvas)
+ }
+ function stopRecording () {
+ // stop capture
+ clearInterval(recordInterval)
+
+ // show output stream
+ invideo.classList.add('hide')
+ outvideo.classList.remove('hide')
+
+ // remove 'recording' border
+ invideo.classList.remove('recording')
+
+ // produce output
+ blob = encoder.compile()
+ console.log('Webm video encoded:', blob.size, 'bytes')
+ outvideo.src = URL.createObjectURL(blob, 'video/webm')
+ enable()
+ }
+
+ return form
+}
\ No newline at end of file
diff --git a/ui/lib/com/webview.js b/ui/lib/com/webview.js
new file mode 100644
index 0000000..cdf627d
--- /dev/null
+++ b/ui/lib/com/webview.js
@@ -0,0 +1,62 @@
+var h = require('hyperscript')
+var muxrpc = require('muxrpc')
+var pull = require('pull-stream')
+var pushable = require('pull-pushable')
+var ssbref = require('ssb-ref')
+var app = require('../app')
+
+var manifest = {
+ 'get' : 'async',
+ 'getPublicKey' : 'async',
+ 'whoami' : 'async',
+ 'relatedMessages' : 'async',
+ 'createFeedStream' : 'source',
+ 'createUserStream' : 'source',
+ 'createLogStream' : 'source',
+ 'messagesByType' : 'source',
+ 'links' : 'source'
+}
+
+module.exports = function (opts) {
+ if (!opts) throw "`opts` required in com.webview"
+
+ var webview = h('webview', { src: opts.url, preload: './webview-preload.js' })
+
+ // setup rpc
+
+ var ssb = muxrpc(null, manifest, serialize)(app.ssb)
+ function serialize (stream) { return stream }
+
+ var rpcStream = ssb.createStream()
+ var ipcPush = pushable()
+ webview.addEventListener('ipc-message', function (e) {
+ if (e.channel == 'navigate') {
+ if (e.args[0] && ssbref.isLink(e.args[0]))
+ window.location.hash = '#/webview/' + e.args[0]
+ else
+ console.warn('Security Error: page attempted to navigate to disallowed location,', e.args[0])
+ }
+ if (e.channel == 'muxrpc-ssb') {
+ var msg = e.args[0]
+ try { msg = JSON.parse(msg) }
+ catch (e) { return }
+ ipcPush.push(msg)
+ }
+ })
+ pull(ipcPush, rpcStream, pull.drain(
+ function (msg) { webview.send('muxrpc-ssb', JSON.stringify(msg)) },
+ function (err) { if (err) { console.error(err) } }
+ ))
+
+ // sandboxing
+
+ // dont let the webview navigate away
+ webview.addEventListener('did-stop-loading', function (e) {
+ if (webview.getUrl().indexOf('http://localhost') !== 0 && webview.getUrl().indexOf('data:') !== 0) {
+ console.warn('Security Error. Webview circumvented navigation sandbox.')
+ webview.src = 'data:text/html,Security Error This page attempted to navigate out of its sandbox through explicit circumvention. Do not trust it!'
+ }
+ })
+
+ return webview
+}
\ No newline at end of file
diff --git a/ui/lib/markdown.js b/ui/lib/markdown.js
new file mode 100644
index 0000000..77b14ce
--- /dev/null
+++ b/ui/lib/markdown.js
@@ -0,0 +1,137 @@
+'use strict'
+var emojiNamedCharacters = require('emoji-named-characters')
+var marked = require('ssb-marked')
+var ssbref = require('ssb-ref')
+var mlib = require('ssb-msgs')
+
+var renderer = new marked.Renderer();
+
+// override to only allow external links or hashes, and correctly link to ssb objects
+renderer.urltransform = function (url) {
+ var c = url.charAt(0)
+ var hasSigil = (c == '@' || c == '&' || c == '%')
+
+ if (this.options.sanitize && !hasSigil) {
+ try {
+ var prot = decodeURIComponent(unescape(url))
+ .replace(/[^\w:]/g, '')
+ .toLowerCase();
+ } catch (e) {
+ return false;
+ }
+ if (prot.indexOf('javascript:') === 0) {
+ return false;
+ }
+ }
+
+ var islink = ssbref.isLink(url)
+ if (hasSigil && !islink && this.options.mentionNames) {
+ // do a name lookup
+ url = this.options.mentionNames[url.slice(1)]
+ if (!url)
+ return false
+ islink = true
+ }
+
+ if (islink) {
+ if (ssbref.isFeedId(url))
+ return '#/profile/'+url
+ else if (ssbref.isMsgId(url))
+ return '#/msg/'+url
+ else if (ssbref.isBlobId(url))
+ return '#/webview/'+url
+ }
+ else if (url.indexOf('http') !== 0) {
+ return false;
+ }
+ return url
+}
+
+// override to make http/s links external
+renderer.link = function(href, title, text) {
+ href = this.urltransform(href)
+ var out
+ if (href !== false)
+ out = '' + text + '';
+ return out;
+};
+
+// override to support | |