Version bump

This commit is contained in:
Neil Lalonde 2019-10-01 16:51:58 -04:00
commit f55439e33e
276 changed files with 4554 additions and 2868 deletions

View File

@ -21,7 +21,7 @@ env:
addons:
chrome: stable
postgresql: 9.6
postgresql: "9.6"
apt:
update: true
packages:
@ -40,10 +40,9 @@ services:
- redis-server
sudo: required
dist: trusty
dist: xenial
cache:
apt: true
yarn: true
directories:
- vendor/bundle

18
Gemfile
View File

@ -16,13 +16,13 @@ if rails_master?
else
# until rubygems gives us optional dependencies we are stuck with this
# bundle update actionmailer actionpack actionview activemodel activerecord activesupport railties
gem 'actionmailer', '5.2.3'
gem 'actionpack', '5.2.3'
gem 'actionview', '5.2.3'
gem 'activemodel', '5.2.3'
gem 'activerecord', '5.2.3'
gem 'activesupport', '5.2.3'
gem 'railties', '5.2.3'
gem 'actionmailer', '6.0.0'
gem 'actionpack', '6.0.0'
gem 'actionview', '6.0.0'
gem 'activemodel', '6.0.0'
gem 'activerecord', '6.0.0'
gem 'activesupport', '6.0.0'
gem 'railties', '6.0.0'
gem 'sprockets-rails'
end
@ -46,7 +46,7 @@ gem 'redis-namespace'
gem 'active_model_serializers', '~> 0.8.3'
gem 'onebox', '1.9.12'
gem 'onebox', '1.9.13'
gem 'http_accept_language', '~>2.0.5', require: false
@ -140,7 +140,7 @@ group :test, :development do
gem 'mocha', require: false
gem 'rb-fsevent', require: RUBY_PLATFORM =~ /darwin/i ? 'rb-fsevent' : false
gem 'rb-inotify', '~> 0.9', require: RUBY_PLATFORM =~ /linux/i ? 'rb-inotify' : false
gem 'rspec-rails', require: false
gem 'rspec-rails', '4.0.0.beta2', require: false
gem 'shoulda-matchers', '~> 3.1', '>= 3.1.3', require: false
gem 'rspec-html-matchers'
gem 'pry-nav'

View File

@ -1,47 +1,46 @@
GEM
remote: https://rubygems.org/
specs:
actionmailer (5.2.3)
actionpack (= 5.2.3)
actionview (= 5.2.3)
activejob (= 5.2.3)
actionmailer (6.0.0)
actionpack (= 6.0.0)
actionview (= 6.0.0)
activejob (= 6.0.0)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (5.2.3)
actionview (= 5.2.3)
activesupport (= 5.2.3)
actionpack (6.0.0)
actionview (= 6.0.0)
activesupport (= 6.0.0)
rack (~> 2.0)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.2.3)
activesupport (= 5.2.3)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actionview (6.0.0)
activesupport (= 6.0.0)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_model_serializers (0.8.4)
activemodel (>= 3.0)
activejob (5.2.3)
activesupport (= 5.2.3)
activejob (6.0.0)
activesupport (= 6.0.0)
globalid (>= 0.3.6)
activemodel (5.2.3)
activesupport (= 5.2.3)
activerecord (5.2.3)
activemodel (= 5.2.3)
activesupport (= 5.2.3)
arel (>= 9.0)
activesupport (5.2.3)
activemodel (6.0.0)
activesupport (= 6.0.0)
activerecord (6.0.0)
activemodel (= 6.0.0)
activesupport (= 6.0.0)
activesupport (6.0.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
tzinfo (~> 1.1)
zeitwerk (~> 2.1, >= 2.1.8)
addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0)
annotate (2.7.5)
activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 13.0)
arel (9.0.0)
ast (2.4.0)
aws-eventstream (1.0.3)
aws-partitions (1.154.0)
@ -183,11 +182,11 @@ GEM
rack (>= 1.1.3)
metaclass (0.0.4)
method_source (0.9.2)
mini_mime (1.0.1)
mini_mime (1.0.2)
mini_portile2 (2.4.0)
mini_racer (0.2.6)
libv8 (>= 6.9.411)
mini_scheduler (0.12.1)
mini_scheduler (0.12.2)
sidekiq
mini_sql (0.2.2)
mini_suffix (0.3.0)
@ -241,7 +240,7 @@ GEM
omniauth-twitter (1.4.0)
omniauth-oauth (~> 1.1)
rack
onebox (1.9.12)
onebox (1.9.13)
htmlentities (~> 4.3)
moneta (~> 1.0)
multi_json (~> 1.11)
@ -282,20 +281,20 @@ GEM
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.0.4)
rails-html-sanitizer (1.2.0)
loofah (~> 2.2, >= 2.2.2)
rails_multisite (2.0.7)
activerecord (> 4.2, < 7)
railties (> 4.2, < 7)
railties (5.2.3)
actionpack (= 5.2.3)
activesupport (= 5.2.3)
railties (6.0.0)
actionpack (= 6.0.0)
activesupport (= 6.0.0)
method_source
rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0)
thor (>= 0.20.3, < 2.0)
rainbow (3.0.0)
raindrops (0.19.0)
rake (12.3.2)
rake (12.3.3)
rake-compiler (1.0.7)
rake
rb-fsevent (0.10.3)
@ -330,14 +329,14 @@ GEM
rspec-mocks (3.8.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.8.0)
rspec-rails (3.8.2)
actionpack (>= 3.0)
activesupport (>= 3.0)
railties (>= 3.0)
rspec-core (~> 3.8.0)
rspec-expectations (~> 3.8.0)
rspec-mocks (~> 3.8.0)
rspec-support (~> 3.8.0)
rspec-rails (4.0.0.beta2)
actionpack (>= 4.2)
activesupport (>= 4.2)
railties (>= 4.2)
rspec-core (~> 3.8)
rspec-expectations (~> 3.8)
rspec-mocks (~> 3.8)
rspec-support (~> 3.8)
rspec-support (3.8.0)
rtlit (0.0.5)
rubocop (0.69.0)
@ -417,18 +416,19 @@ GEM
hkdf (~> 0.2)
jwt (~> 2.0)
yaml-lint (0.0.10)
zeitwerk (2.1.10)
PLATFORMS
ruby
DEPENDENCIES
actionmailer (= 5.2.3)
actionpack (= 5.2.3)
actionview (= 5.2.3)
actionmailer (= 6.0.0)
actionpack (= 6.0.0)
actionview (= 6.0.0)
active_model_serializers (~> 0.8.3)
activemodel (= 5.2.3)
activerecord (= 5.2.3)
activesupport (= 5.2.3)
activemodel (= 6.0.0)
activerecord (= 6.0.0)
activesupport (= 6.0.0)
annotate
aws-sdk-s3
aws-sdk-sns
@ -493,7 +493,7 @@ DEPENDENCIES
omniauth-oauth2
omniauth-openid
omniauth-twitter
onebox (= 1.9.12)
onebox (= 1.9.13)
openid-redis-store
parallel_tests
pg
@ -504,7 +504,7 @@ DEPENDENCIES
rack-mini-profiler
rack-protection
rails_multisite
railties (= 5.2.3)
railties (= 6.0.0)
rake
rb-fsevent
rb-inotify (~> 0.9)
@ -517,7 +517,7 @@ DEPENDENCIES
rqrcode
rspec
rspec-html-matchers
rspec-rails
rspec-rails (= 4.0.0.beta2)
rtlit
rubocop
ruby-prof

View File

@ -63,7 +63,7 @@ Backup.reopenClass({
bootbox.alert(result.message);
} else {
// redirect to homepage (session might be lost)
window.location.pathname = Discourse.getURL("/");
window.location = Discourse.getURL("/");
}
});
}

View File

@ -1,7 +1,11 @@
import { escapeExpression } from "discourse/lib/utilities";
import { ajax } from "discourse/lib/ajax";
import round from "discourse/lib/round";
import { fillMissingDates, formatUsername } from "discourse/lib/utilities";
import {
fillMissingDates,
formatUsername,
toNumber
} from "discourse/lib/utilities";
import computed from "ember-addons/ember-computed-decorators";
import { number, durationTiny } from "discourse/lib/formatter";
import { renderAvatar } from "discourse/helpers/user-avatar";
@ -374,14 +378,14 @@ const Report = Discourse.Model.extend({
_secondsLabel(value) {
return {
value,
value: toNumber(value),
formatedValue: durationTiny(value)
};
},
_percentLabel(value) {
return {
value,
value: toNumber(value),
formatedValue: value ? `${value}%` : "—"
};
},
@ -394,14 +398,14 @@ const Report = Discourse.Model.extend({
const formatedValue = () => (formatNumbers ? number(value) : value);
return {
value,
value: toNumber(value),
formatedValue: value ? formatedValue() : "—"
};
},
_bytesLabel(value) {
return {
value,
value: toNumber(value),
formatedValue: I18n.toHumanSize(value)
};
},

View File

@ -19,16 +19,18 @@ const VersionCheck = Discourse.Model.extend({
@computed("git_branch", "installed_sha")
gitLink(gitBranch, installedSHA) {
if (gitBranch) {
if (gitBranch && installedSHA) {
return `https://github.com/discourse/discourse/compare/${installedSHA}...${gitBranch}`;
} else {
} else if (installedSHA) {
return `https://github.com/discourse/discourse/tree/${installedSHA}`;
}
},
@computed("installed_sha")
shortSha(installedSHA) {
return installedSHA.substr(0, 10);
if (installedSHA) {
return installedSHA.substr(0, 10);
}
}
});

View File

@ -36,7 +36,7 @@ export default Discourse.Route.extend({
);
if (log.operation === "restore") {
// redirect to homepage when the restore is done (session might be lost)
window.location.pathname = Discourse.getURL("/");
window.location = Discourse.getURL("/");
}
} else {
this.controllerFor("adminBackupsLogs")

View File

@ -5,7 +5,12 @@
<div class="version-number">
<h4>{{i18n 'admin.dashboard.installed_version'}}</h4>
<h3><a href={{versionCheck.gitLink}} target="_blank">{{dash-if-empty versionCheck.installed_describe}}</a></h3>
<h3>{{dash-if-empty versionCheck.installed_version}}</h3>
{{#if versionCheck.gitLink}}
<div class="sha-link">
(<a href={{versionCheck.gitLink}} target="_blank">{{versionCheck.shortSha}}</a>)
</div>
{{/if}}
</div>
{{#if versionCheck.noCheckPerformed}}

View File

@ -24,12 +24,16 @@ export default Ember.Mixin.create({
Discourse.set("hasFocus", true);
document.addEventListener("visibilitychange", onchange);
document.addEventListener("resume", onchange);
document.addEventListener("freeze", onchange);
},
reset() {
this._super(...arguments);
document.removeEventListener("visibilitychange", onchange);
document.removeEventListener("resume", onchange);
document.removeEventListener("freeze", onchange);
onchange = undefined;
}

View File

@ -131,6 +131,25 @@ export default Ember.Component.extend(KeyEnterEscape, {
$document.on(DRAG_EVENTS, throttledPerformDrag);
$document.on(END_EVENTS, endDrag);
});
if (window.visualViewport !== undefined) {
this.viewportResize();
window.visualViewport.addEventListener("resize", this.viewportResize);
}
},
viewportResize() {
const composerVH = window.visualViewport.height * 0.01;
if (window.visualViewport.height !== window.innerHeight) {
document.documentElement.classList.add("keyboard-visible");
} else {
document.documentElement.classList.remove("keyboard-visible");
}
document.documentElement.style.setProperty(
"--composer-vh",
`${composerVH}px`
);
},
didInsertElement() {
@ -155,6 +174,9 @@ export default Ember.Component.extend(KeyEnterEscape, {
willDestroyElement() {
this._super(...arguments);
this.appEvents.off("composer:resize", this, this.resize);
if (window.visualViewport !== undefined) {
window.visualViewport.removeEventListener("resize", this.viewportResize);
}
},
click() {

View File

@ -1026,39 +1026,37 @@ export default Ember.Component.extend({
Ember.run.debounce(
this,
() => {
const inlineOneboxes = {};
const oneboxes = {};
const inlineOneboxes = {};
let oneboxLeft =
this.siteSettings.max_oneboxes_per_post -
$(
`aside.onebox, a.${INLINE_ONEBOX_CSS_CLASS}, a.${LOADING_ONEBOX_CSS_CLASS}`
).length;
// Oneboxes = `a.onebox` -> `a.onebox-loading` -> `aside.onebox`
// Inline Oneboxes = `a.inline-onebox-loading` -> `a.inline-onebox`
let loadedOneboxes = $preview.find(
`aside.onebox, a.${LOADING_ONEBOX_CSS_CLASS}, a.${INLINE_ONEBOX_CSS_CLASS}`
).length;
$preview
.find(`a.${INLINE_ONEBOX_LOADING_CSS_CLASS}, a.onebox`)
.each((_index, link) => {
.find(`a.onebox, a.${INLINE_ONEBOX_LOADING_CSS_CLASS}`)
.each((_, link) => {
const $link = $(link);
const text = $link.text();
const isInline =
$link.attr("class") === INLINE_ONEBOX_LOADING_CSS_CLASS;
const m = isInline ? inlineOneboxes : oneboxes;
const map = isInline ? inlineOneboxes : oneboxes;
if (oneboxLeft <= 0) {
if (map[text] !== undefined) {
map[text].push(link);
if (loadedOneboxes < this.siteSettings.max_oneboxes_per_post) {
if (m[text] === undefined) {
m[text] = [];
loadedOneboxes++;
}
m[text].push(link);
} else {
if (m[text] !== undefined) {
m[text].push(link);
} else if (isInline) {
$link.removeClass(INLINE_ONEBOX_LOADING_CSS_CLASS);
}
} else {
if (!map[text]) {
map[text] = [];
oneboxLeft--;
}
map[text].push(link);
}
});
@ -1072,6 +1070,7 @@ export default Ember.Component.extend({
},
450
);
// Short upload urls need resolution
resolveAllShortUrls(ajax);

View File

@ -2,7 +2,6 @@ import MountWidget from "discourse/components/mount-widget";
import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction";
import Scrolling from "discourse/mixins/scrolling";
import { observes } from "ember-addons/ember-computed-decorators";
import { isiPad } from "discourse/lib/utilities";
import { isAppWebview, postRNWebviewMessage } from "discourse/lib/utilities";
const MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE = 150;
@ -37,7 +36,7 @@ const FooterNavComponent = MountWidget.extend(
this.appEvents.on("modal:body-dismissed", this, "_modalOff");
}
if (isiPad()) {
if (this.capabilities.isIpadOS) {
$("body").addClass("footer-nav-ipad");
} else {
this.bindScrolling({ name: "footer-nav" });
@ -56,7 +55,7 @@ const FooterNavComponent = MountWidget.extend(
this.appEvents.off("modal:body-removed", this, "_modalOff");
}
if (isiPad()) {
if (this.capabilities.isIpadOS) {
$("body").removeClass("footer-nav-ipad");
} else {
this.unbindScrolling("footer-nav");

View File

@ -170,7 +170,7 @@ export default Ember.Component.extend({
)
.on("keydown.share-view", this._boundKeydownHandler);
this.appEvents.on("share:url", this._shareUrlHandler);
this.appEvents.on("share:url", this, "_shareUrlHandler");
},
willDestroyElement() {
@ -181,7 +181,7 @@ export default Ember.Component.extend({
.off("mousedown.outside-share-link", this._boundMouseDownHandler)
.off("keydown.share-view", this._boundKeydownHandler);
this.appEvents.off("share:url", this._shareUrlHandler);
this.appEvents.off("share:url", this, "_shareUrlHandler");
},
actions: {

View File

@ -362,6 +362,12 @@ export default SiteHeaderComponent;
export function headerHeight() {
const $header = $("header.d-header");
// Header may not exist in tests (e.g. in the user menu component test).
if ($header.length === 0) {
return 0;
}
const headerOffset = $header.offset();
const headerOffsetTop = headerOffset ? headerOffset.top : 0;
return parseInt(

View File

@ -158,12 +158,17 @@ export default Ember.Component.extend({
const offset = window.pageYOffset || $html.scrollTop();
const progressHeight = this.site.mobileView
? 0
: $("#topic-progress").height();
: $("#topic-progress").outerHeight();
const maximumOffset = $("#topic-bottom").offset().top + progressHeight;
const windowHeight = $(window).height();
const composerHeight = $("#reply-control").height() || 0;
const isDocked = offset >= maximumOffset - windowHeight + composerHeight;
const bottom = $("body").height() - maximumOffset;
let bottom = $("body").height() - maximumOffset;
const $iPadFooterNav = $(".footer-nav-ipad .footer-nav");
if ($iPadFooterNav && $iPadFooterNav.length > 0) {
bottom += $iPadFooterNav.outerHeight();
}
const wrapperDir = $html.hasClass("rtl") ? "left" : "right";
if (composerHeight > 0) {
@ -175,7 +180,7 @@ export default Ember.Component.extend({
this.set("docked", isDocked);
const $replyArea = $("#reply-control .reply-area");
if ($replyArea && $replyArea.length > 0) {
if ($replyArea && $replyArea.length > 0 && wrapperDir === "left") {
$wrapper.css(wrapperDir, `${$replyArea.offset().left}px`);
} else {
$wrapper.css(wrapperDir, "1em");

View File

@ -70,13 +70,16 @@ export default Ember.Component.extend(LoadMore, {
} else {
Draft.get(item.draft_key)
.then(d => {
if (d.draft) {
composer.open({
draft: d.draft,
draftKey: item.draft_key,
draftSequence: d.draft_sequence
});
const draft = d.draft || item.data;
if (!draft) {
return;
}
composer.open({
draft,
draftKey: item.draft_key,
draftSequence: d.draft_sequence
});
})
.catch(error => {
popupAjaxError(error);

View File

@ -217,7 +217,10 @@ export default Ember.Controller.extend(ModalFunctionality, {
// On Mobile, Android or iOS always go with full screen
if (
this.isMobileDevice ||
(capabilities && (capabilities.isIOS || capabilities.isAndroid))
(capabilities &&
(capabilities.isIOS ||
capabilities.isAndroid ||
capabilities.isSafari))
) {
fullScreenLogin = true;
}
@ -346,7 +349,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
$.removeCookie("destination_url");
window.location.href = destinationUrl;
} else if (window.location.pathname === Discourse.getURL("/login")) {
window.location.pathname = Discourse.getURL("/");
window.location = Discourse.getURL("/");
} else {
window.location.reload();
}

View File

@ -190,7 +190,7 @@ export default Ember.Controller.extend(
() => {
bootbox.alert(
I18n.t("user.deleted_yourself"),
() => (window.location.pathname = Discourse.getURL("/"))
() => (window.location = Discourse.getURL("/"))
);
},
() => {
@ -238,7 +238,7 @@ export default Ember.Controller.extend(
if (!token) {
const redirect = this.siteSettings.logout_redirect;
if (Ember.isEmpty(redirect)) {
window.location.pathname = Discourse.getURL("/");
window.location = Discourse.getURL("/");
} else {
window.location.href = redirect;
}

View File

@ -690,20 +690,7 @@ export default Ember.Controller.extend(bufferedProperty("model"), {
},
jumpToPost(postNumber) {
if (this.get("model.postStream.isMegaTopic")) {
this._jumpToPostNumber(postNumber);
} else {
const postStream = this.get("model.postStream");
let postId = postStream.findPostIdForPostNumber(postNumber);
// If we couldn't find the post, find the closest post to it
if (!postId) {
const closest = postStream.closestPostNumberFor(postNumber);
postId = postStream.findPostIdForPostNumber(closest);
}
this._jumpToPostId(postId);
}
this._jumpToPostNumber(postNumber);
},
jumpTop() {
@ -1352,17 +1339,12 @@ export default Ember.Controller.extend(bufferedProperty("model"), {
})
.then(() => refresh({ id: data.id, refreshLikes: true }));
break;
case "read":
case "read": {
postStream
.triggerChangedPost(data.id, data.updated_at, {
preserveCooked: true
})
.then(() =>
refresh({
id: data.id,
refreshReaders: topic.show_read_indicator
})
);
.triggerReadPost(data.id, data.readers_count)
.then(() => refresh({ id: data.id, refreshLikes: true }));
break;
}
case "revised":
case "rebaked": {
postStream

View File

@ -75,7 +75,7 @@ export function categoryLinkHTML(category, options) {
registerUnbound("category-link", categoryLinkHTML);
function defaultCategoryLinkRenderer(category, opts) {
let description = get(category, "description_text");
let descriptionText = get(category, "description_text");
let restricted = get(category, "read_restricted");
let url = opts.url
? opts.url
@ -121,7 +121,7 @@ function defaultCategoryLinkRenderer(category, opts) {
'data-drop-close="true" class="' +
classNames +
'"' +
(description ? 'title="' + escapeExpression(description) + '" ' : "") +
(descriptionText ? 'title="' + descriptionText + '" ' : "") +
">";
let categoryName = escapeExpression(get(category, "name"));

View File

@ -3,9 +3,17 @@ import { updateRelativeAge } from "discourse/lib/formatter";
// Updates the relative ages of dates on the screen.
export default {
name: "relative-ages",
initialize: function() {
setInterval(function() {
initialize() {
this._interval = setInterval(function() {
updateRelativeAge($(".relative-date"));
}, 60 * 1000);
},
teardown() {
if (this._interval) {
clearInterval(this._interval);
this._interval = null;
}
}
};

View File

@ -159,7 +159,7 @@ export function ajax() {
if (
args.type &&
args.type.toUpperCase() !== "GET" &&
url !== "/clicks/track" &&
url !== Discourse.getURL("/clicks/track") &&
!Discourse.Session.currentProp("csrfToken")
) {
promise = new Ember.RSVP.Promise((resolve, reject) => {

View File

@ -102,9 +102,9 @@ export default {
data.append("url", href);
data.append("post_id", postId);
data.append("topic_id", topicId);
navigator.sendBeacon("/clicks/track", data);
navigator.sendBeacon(Discourse.getURL("/clicks/track"), data);
} else {
trackPromise = ajax("/clicks/track", {
trackPromise = ajax(Discourse.getURL("/clicks/track"), {
type: "POST",
data: {
url: href,

View File

@ -262,7 +262,7 @@ function relativeAgeTinyShowsYear(relativeAgeString) {
return relativeAgeString.match(/'[\d]{2}$/);
}
function relativeAgeMediumSpan(distance, leaveAgo) {
export function relativeAgeMediumSpan(distance, leaveAgo) {
let formatted;
const distanceInMinutes = Math.round(distance / 60.0);
@ -283,14 +283,24 @@ function relativeAgeMediumSpan(distance, leaveAgo) {
case distanceInMinutes >= 90 && distanceInMinutes <= 1409:
formatted = t("x_hours", { count: Math.round(distanceInMinutes / 60.0) });
break;
case distanceInMinutes >= 1410 && distanceInMinutes <= 2159:
case distanceInMinutes >= 1410 && distanceInMinutes <= 2519:
formatted = t("x_days", { count: 1 });
break;
case distanceInMinutes >= 2160:
case distanceInMinutes >= 2520 && distanceInMinutes <= 129599:
formatted = t("x_days", {
count: Math.round((distanceInMinutes - 720.0) / 1440.0)
});
break;
case distanceInMinutes >= 129600 && distanceInMinutes <= 525599:
formatted = t("x_months", {
count: Math.round(distanceInMinutes / 43200.0)
});
break;
default:
formatted = t("x_years", {
count: Math.round(distanceInMinutes / 525600.0)
});
break;
}
return formatted || "&mdash;";
}

View File

@ -9,7 +9,7 @@ export default function logout(siteSettings, keyValueStore) {
const redirect = siteSettings.logout_redirect;
if (Ember.isEmpty(redirect)) {
window.location.pathname = Discourse.getURL("/");
window.location = Discourse.getURL("/");
} else {
window.location.href = redirect;
}

View File

@ -1,5 +1,8 @@
import debounce from "discourse/lib/debounce";
import { isAppleDevice, safariHacksDisabled } from "discourse/lib/utilities";
import { safariHacksDisabled } from "discourse/lib/utilities";
// TODO: remove calcHeight once iOS 13 adoption > 90%
// In iOS 13 and up we use visualViewport API to calculate height
// we can't tell what the actual visible window height is
// because we cannot account for the height of the mobile keyboard
@ -70,7 +73,9 @@ export function isWorkaroundActive() {
// per http://stackoverflow.com/questions/29001977/safari-in-ios8-is-scrolling-screen-when-fixed-elements-get-focus/29064810
function positioningWorkaround($fixedElement) {
if (!isAppleDevice() || safariHacksDisabled()) {
const caps = Discourse.__container__.lookup("capabilities:main");
if (!caps.isIOS || caps.isIpadOS || safariHacksDisabled()) {
return;
}
@ -89,9 +94,14 @@ function positioningWorkaround($fixedElement) {
fixedElement.style.position = "";
fixedElement.style.top = "";
fixedElement.style.height = oldHeight;
Ember.run.later(() => $(fixedElement).removeClass("no-transition"), 500);
if (window.visualViewport === undefined) {
fixedElement.style.height = oldHeight;
Ember.run.later(
() => $(fixedElement).removeClass("no-transition"),
500
);
}
$(window).scrollTop(originalScrollTop);
@ -165,10 +175,11 @@ function positioningWorkaround($fixedElement) {
fixedElement.style.top = "0px";
const height = calcHeight();
fixedElement.style.height = height + "px";
$(fixedElement).addClass("no-transition");
if (window.visualViewport === undefined) {
const height = calcHeight();
fixedElement.style.height = height + "px";
$(fixedElement).addClass("no-transition");
}
evt.preventDefault();
this.focus();

View File

@ -4,6 +4,7 @@ import Category from "discourse/models/category";
import { search as searchCategoryTag } from "discourse/lib/category-tag-search";
import userSearch from "discourse/lib/user-search";
import { userPath } from "discourse/lib/url";
import { emojiUnescape } from "discourse/lib/text";
import User from "discourse/models/user";
import Post from "discourse/models/post";
import Topic from "discourse/models/topic";
@ -31,6 +32,7 @@ export function translateResults(results, opts) {
}
post = Post.create(post);
post.set("topic", topicMap[post.topic_id]);
post.blurb = emojiUnescape(post.blurb);
return post;
});

View File

@ -19,6 +19,11 @@
return "http://twitter.com/intent/tweet?url=" + encodeURIComponent(link) + "&text=" + encodeURIComponent(title);
},
// If provided, handle by custom javascript rather than default url open
clickHandler: function(link, title){
alert("Hello!")
}
// If true, opens in a popup of `popupHeight` size. If false it's opened in a new tab
shouldOpenInPopup: true,
popupHeight: 265
@ -48,23 +53,27 @@ export default {
},
shareSource(source, data) {
const url = source.generateUrl(data.url, data.title);
const options = {
menubar: "no",
toolbar: "no",
resizable: "yes",
scrollbars: "yes",
width: 600,
height: source.popupHeight || 315
};
const stringOptions = Object.keys(options)
.map(k => `${k}=${options[k]}`)
.join(",");
if (source.shouldOpenInPopup) {
window.open(url, "", stringOptions);
if (source.clickHandler) {
source.clickHandler(data.url, data.title);
} else {
window.open(url, "_blank");
const url = source.generateUrl(data.url, data.title);
const options = {
menubar: "no",
toolbar: "no",
resizable: "yes",
scrollbars: "yes",
width: 600,
height: source.popupHeight || 315
};
const stringOptions = Object.keys(options)
.map(k => `${k}=${options[k]}`)
.join(",");
if (source.shouldOpenInPopup) {
window.open(url, "", stringOptions);
} else {
window.open(url, "_blank");
}
}
},

View File

@ -538,10 +538,8 @@ export function determinePostReplaceSelection({
export function isAppleDevice() {
// IE has no DOMNodeInserted so can not get this hack despite saying it is like iPhone
// This will apply hack on all iDevices
return (
navigator.userAgent.match(/(iPad|iPhone|iPod)/g) &&
!navigator.userAgent.match(/Trident/g)
);
const caps = Discourse.__container__.lookup("capabilities:main");
return caps.isIOS && !navigator.userAgent.match(/Trident/g);
}
let iPadDetected = undefined;
@ -599,8 +597,12 @@ export function clipboardData(e, canUpload) {
return { clipboard, types, canUpload, canPasteHtml };
}
export function toNumber(input) {
return typeof input === "number" ? input : parseFloat(input);
}
export function isNumeric(input) {
return !isNaN(parseFloat(input)) && isFinite(input);
return !isNaN(toNumber(input)) && isFinite(input);
}
export function fillMissingDates(data, startDate, endDate) {

View File

@ -358,6 +358,17 @@ const Composer = RestModel.extend({
}
}
if (topicFirstPost) {
// user should modify topic template
const category = this.category;
if (category && category.topic_template) {
if (this.reply.trim() === category.topic_template.trim()) {
bootbox.alert(I18n.t("composer.error.topic_template_not_modified"));
return true;
}
}
}
if (this.privateMessage) {
// need at least one user when sending a PM
return (

View File

@ -19,7 +19,10 @@ const NavItem = Discourse.Model.extend({
displayName(categoryName, name, count) {
count = count || 0;
if (name === "latest" && !Discourse.Site.currentProp("mobileView")) {
if (
name === "latest" &&
(!Discourse.Site.currentProp("mobileView") || this.tagId !== undefined)
) {
count = 0;
}

View File

@ -716,6 +716,19 @@ export default RestModel.extend({
return resolved;
},
triggerReadPost(postId, readersCount) {
const resolved = Ember.RSVP.Promise.resolve();
resolved.then(() => {
const post = this.findLoadedPost(postId);
if (post && readersCount > post.readers_count) {
post.set("readers_count", readersCount);
this.storePost(post);
}
});
return resolved;
},
postForPostNumber(postNumber) {
if (!this.hasPosts) {
return;

View File

@ -35,10 +35,14 @@ export default {
caps.canPasteImages = caps.isChrome || caps.isFirefox;
}
caps.isIOS =
/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
caps.isIpadOS =
ua.indexOf("Mac OS") !== -1 &&
!/iPhone|iPod/.test(navigator.userAgent) &&
touch;
caps.isIpadOS = ua.indexOf("Mac OS") !== -1 && touch;
caps.isIOS =
(/iPhone|iPod/.test(navigator.userAgent) || caps.isIpadOS) &&
!window.MSStream;
}
// We consider high res a device with 1280 horizontal pixels. High DPI tablets like

View File

@ -7,7 +7,7 @@ export default Discourse.Route.extend({
const params = this.paramsFor("associate-account");
this.replaceWith(`preferences.account`, this.currentUser).then(() =>
Ember.run.next(() =>
ajax(`/associate/${encodeURIComponent(params.token)}`)
ajax(`/associate/${encodeURIComponent(params.token)}.json`)
.then(model => showModal("associate-account-confirm", { model }))
.catch(popupAjaxError)
)

View File

@ -59,7 +59,7 @@
{{#if model.category_moderators.length}}
{{#each model.category_moderators as |cm|}}
<section class='about category-moderators'>
<section class='about category-moderators moderators-{{cm.category.slug}}'>
<h3>{{category-link cm.category}}{{i18n "about.moderators"}}</h3>
<div class='users'>
{{#each cm.moderators as |m|}}

View File

@ -1,5 +1,12 @@
{{#if showInput}}
{{yield}}
{{else}}
<a href>{{i18n key}}</a>
<a href>
{{#if key}}
{{i18n key}}
{{/if}}
{{#if icon}}
{{d-icon icon}}
{{/if}}
</a>
{{/if}}

View File

@ -9,7 +9,7 @@
disabled=reviewableUpdating}}
{{else}}
{{d-button
class=(concat "reviewable-action " (dasherize first.id))
class=(concat "reviewable-action " (dasherize first.id) " " first.button_class)
icon=first.icon
action=(action "perform" first)
translatedLabel=first.label

View File

@ -37,7 +37,7 @@
{{/unless}}
{{#if canEdit}}
{{#link-to-input onClick=(action "displayEditReason") showInput=showEditReason key="composer.show_edit_reason" class="display-edit-reason"}}
{{#link-to-input onClick=(action "displayEditReason") showInput=showEditReason icon="info-circle" class="display-edit-reason"}}
{{text-field value=editReason tabindex="7" id="edit-reason" maxlength="255" placeholderKey="composer.edit_reason_placeholder"}}
{{/link-to-input}}
{{/if}}

View File

@ -54,7 +54,7 @@
{{/d-section}}
<section class='user-content user-preferences'>
{{plugin-outlet name="above-user-preferences"}}
{{plugin-outlet name="above-user-preferences" args=(hash model=model)}}
<form class="form-vertical">
{{outlet}}

View File

@ -1,3 +1,4 @@
{{plugin-outlet name="above-user-profile" tagName='' args=(hash model=model)}}
<div class="container {{if viewingSelf 'viewing-self'}}">
{{#d-section class="user-main"}}

View File

@ -14,7 +14,7 @@ import {
formatUsername
} from "discourse/lib/utilities";
import hbs from "discourse/widgets/hbs-compiler";
import { durationTiny } from "discourse/lib/formatter";
import { relativeAgeMediumSpan } from "discourse/lib/formatter";
import { prioritizeNameInUx } from "discourse/lib/settings";
function transformWithCallbacks(post) {
@ -472,7 +472,7 @@ createWidget("post-notice", {
"p",
I18n.t("post.notice.returning_user", {
user,
time: durationTiny(distance, { addAgo: true })
time: relativeAgeMediumSpan(distance, true)
})
);
}

View File

@ -0,0 +1,51 @@
import { h } from "virtual-dom";
import QuickAccessPanel from "discourse/widgets/quick-access-panel";
import UserAction from "discourse/models/user-action";
import { ajax } from "discourse/lib/ajax";
import { createWidgetFrom } from "discourse/widgets/widget";
import { postUrl } from "discourse/lib/utilities";
const ICON = "bookmark";
createWidgetFrom(QuickAccessPanel, "quick-access-bookmarks", {
buildKey: () => "quick-access-bookmarks",
hasMore() {
// Always show the button to the bookmarks page.
return true;
},
showAllHref() {
return `${this.attrs.path}/activity/bookmarks`;
},
emptyStatePlaceholderItem() {
return h("li.read", this.state.emptyStatePlaceholderItemText);
},
findNewItems() {
return ajax("/user_actions.json", {
cache: "false",
data: {
username: this.currentUser.username,
filter: UserAction.TYPES.bookmarks,
limit: this.estimateItemLimit(),
no_results_help_key: "user_activity.no_bookmarks"
}
}).then(({ user_actions, no_results_help }) => {
// The empty state help text for bookmarks page is localized on the
// server.
this.state.emptyStatePlaceholderItemText = no_results_help;
return user_actions;
});
},
itemHtml(bookmark) {
return this.attach("quick-access-item", {
icon: ICON,
href: postUrl(bookmark.slug, bookmark.topic_id, bookmark.post_number),
content: bookmark.title,
username: bookmark.username
});
}
});

View File

@ -0,0 +1,62 @@
import { h } from "virtual-dom";
import RawHtml from "discourse/widgets/raw-html";
import { createWidget } from "discourse/widgets/widget";
import { emojiUnescape } from "discourse/lib/text";
import { iconNode } from "discourse-common/lib/icon-library";
import { escapeExpression } from "discourse/lib/utilities";
/**
* This helper widget tries to enforce a consistent look and behavior for any
* item under any quick access panels.
*
* It accepts the following attributes:
* action
* actionParam
* content
* escapedContent
* href
* icon
* read
* username
*/
createWidget("quick-access-item", {
tagName: "li",
buildClasses(attrs) {
const result = [];
if (attrs.className) {
result.push(attrs.className);
}
if (attrs.read === undefined || attrs.read) {
result.push("read");
}
return result;
},
html({ icon, href }) {
return h("a", { attributes: { href } }, [
iconNode(icon),
new RawHtml({
html: `<div>${this._usernameHtml()}${this._contentHtml()}</div>`
})
]);
},
click(e) {
this.attrs.read = true;
if (this.attrs.action) {
e.preventDefault();
return this.sendWidgetAction(this.attrs.action, this.attrs.actionParam);
}
},
_contentHtml() {
const content =
this.attrs.escapedContent || escapeExpression(this.attrs.content);
return emojiUnescape(content);
},
_usernameHtml() {
return this.attrs.username ? `<span>${this.attrs.username}</span> ` : "";
}
});

View File

@ -0,0 +1,50 @@
import QuickAccessPanel from "discourse/widgets/quick-access-panel";
import { createWidgetFrom } from "discourse/widgets/widget";
import { postUrl } from "discourse/lib/utilities";
const ICON = "notification.private_message";
function toItem(message) {
const lastReadPostNumber = message.last_read_post_number || 0;
const nextUnreadPostNumber = Math.min(
lastReadPostNumber + 1,
message.highest_post_number
);
return {
escapedContent: message.fancy_title,
href: postUrl(message.slug, message.id, nextUnreadPostNumber),
icon: ICON,
read: message.last_read_post_number >= message.highest_post_number,
username: message.last_poster_username
};
}
createWidgetFrom(QuickAccessPanel, "quick-access-messages", {
buildKey: () => "quick-access-messages",
emptyStatePlaceholderItemKey: "choose_topic.none_found",
hasMore() {
// Always show the button to the messages page for composing, archiving,
// etc.
return true;
},
showAllHref() {
return `${this.attrs.path}/messages`;
},
findNewItems() {
return this.store
.findFiltered("topicList", {
filter: `topics/private-messages/${this.currentUser.username_lower}`
})
.then(({ topic_list }) => {
return topic_list.topics.map(toItem).slice(0, this.estimateItemLimit());
});
},
itemHtml(message) {
return this.attach("quick-access-item", message);
}
});

View File

@ -0,0 +1,55 @@
import { ajax } from "discourse/lib/ajax";
import { createWidgetFrom } from "discourse/widgets/widget";
import QuickAccessPanel from "discourse/widgets/quick-access-panel";
createWidgetFrom(QuickAccessPanel, "quick-access-notifications", {
buildKey: () => "quick-access-notifications",
emptyStatePlaceholderItemKey: "notifications.empty",
markReadRequest() {
return ajax("/notifications/mark-read", { method: "PUT" });
},
newItemsLoaded() {
if (!this.currentUser.enforcedSecondFactor) {
this.currentUser.set("unread_notifications", 0);
}
},
itemHtml(notification) {
const notificationName = this.site.notificationLookup[
notification.notification_type
];
return this.attach(
`${notificationName.dasherize()}-notification-item`,
notification,
{},
{ fallbackWidgetName: "default-notification-item" }
);
},
findNewItems() {
return this._findStaleItemsInStore().refresh();
},
showAllHref() {
return `${this.attrs.path}/notifications`;
},
hasUnread() {
return this.getItems().filterBy("read", false).length > 0;
},
_findStaleItemsInStore() {
return this.store.findStale(
"notification",
{
recent: true,
silent: this.currentUser.enforcedSecondFactor,
limit: this.estimateItemLimit()
},
{ cacheKey: "recent-notifications" }
);
}
});

View File

@ -0,0 +1,143 @@
import Session from "discourse/models/session";
import { createWidget } from "discourse/widgets/widget";
import { h } from "virtual-dom";
import { headerHeight } from "discourse/components/site-header";
const AVERAGE_ITEM_HEIGHT = 55;
/**
* This tries to enforce a consistent flow of fetching, caching, refreshing,
* and rendering for "quick access items".
*
* There are parts to introducing a new quick access panel:
* 1. A user menu link that sends a `quickAccess` action, with a unique `type`.
* 2. A `quick-access-${type}` widget, extended from `quick-access-panel`.
*/
export default createWidget("quick-access-panel", {
tagName: "div.quick-access-panel",
emptyStatePlaceholderItemKey: "",
buildKey: () => {
throw Error('Cannot attach abstract widget "quick-access-panel".');
},
markReadRequest() {
return Ember.RSVP.Promise.resolve();
},
hasUnread() {
return false;
},
showAllHref() {
return "";
},
hasMore() {
return this.getItems().length >= this.estimateItemLimit();
},
findNewItems() {
return Ember.RSVP.Promise.resolve([]);
},
newItemsLoaded() {},
itemHtml(item) {}, // eslint-disable-line no-unused-vars
emptyStatePlaceholderItem() {
if (this.emptyStatePlaceholderItemKey) {
return h("li.read", I18n.t(this.emptyStatePlaceholderItemKey));
} else {
return "";
}
},
defaultState() {
return { items: [], loading: false, loaded: false };
},
markRead() {
return this.markReadRequest().then(() => {
this.refreshNotifications(this.state);
});
},
estimateItemLimit() {
// Estimate (poorly) the amount of notifications to return.
let limit = Math.round(
($(window).height() - headerHeight()) / AVERAGE_ITEM_HEIGHT
);
// We REALLY don't want to be asking for negative counts of notifications
// less than 5 is also not that useful.
if (limit < 5) {
limit = 5;
} else if (limit > 40) {
limit = 40;
}
return limit;
},
refreshNotifications(state) {
if (this.loading) {
return;
}
if (this.getItems().length === 0) {
state.loading = true;
}
this.findNewItems()
.then(newItems => this.setItems(newItems))
.catch(() => this.setItems([]))
.finally(() => {
state.loading = false;
state.loaded = true;
this.newItemsLoaded();
this.sendWidgetAction("itemsLoaded", {
hasUnread: this.hasUnread(),
markRead: () => this.markRead()
});
this.scheduleRerender();
});
},
html(attrs, state) {
if (!state.loaded) {
this.refreshNotifications(state);
}
if (state.loading) {
return [h("div.spinner-container", h("div.spinner"))];
}
const items = this.getItems().length
? this.getItems().map(item => this.itemHtml(item))
: [this.emptyStatePlaceholderItem()];
if (this.hasMore()) {
items.push(
h(
"li.read.last.show-all",
this.attach("link", {
title: "view_all",
icon: "chevron-down",
href: this.showAllHref()
})
)
);
}
return [h("ul", items)];
},
getItems() {
return Session.currentProp(`${this.key}-items`) || [];
},
setItems(newItems) {
Session.currentProp(`${this.key}-items`, newItems);
}
});

View File

@ -0,0 +1,91 @@
import QuickAccessPanel from "discourse/widgets/quick-access-panel";
import { createWidgetFrom } from "discourse/widgets/widget";
createWidgetFrom(QuickAccessPanel, "quick-access-profile", {
buildKey: () => "quick-access-profile",
hasMore() {
// Never show the button to the full profile page.
return false;
},
findNewItems() {
return Ember.RSVP.Promise.resolve(this._getItems());
},
itemHtml(item) {
return this.attach("quick-access-item", item);
},
_getItems() {
const items = this._getDefaultItems();
if (this._showToggleAnonymousButton()) {
items.push(this._toggleAnonymousButton());
}
if (this.attrs.showLogoutButton) {
items.push(this._logOutButton());
}
return items;
},
_getDefaultItems() {
return [
{
icon: "user",
href: `${this.attrs.path}/summary`,
content: I18n.t("user.summary.title")
},
{
icon: "stream",
href: `${this.attrs.path}/activity`,
content: I18n.t("user.activity_stream")
},
{
icon: "envelope",
href: `${this.attrs.path}/messages`,
content: I18n.t("user.private_messages")
},
{
icon: "cog",
href: `${this.attrs.path}/preferences`,
content: I18n.t("user.preferences")
}
];
},
_toggleAnonymousButton() {
if (this.currentUser.is_anonymous) {
return {
action: "toggleAnonymous",
className: "disable-anonymous",
content: I18n.t("switch_from_anon"),
icon: "ban"
};
} else {
return {
action: "toggleAnonymous",
className: "enable-anonymous",
content: I18n.t("switch_to_anon"),
icon: "user-secret"
};
}
},
_logOutButton() {
return {
action: "logout",
className: "logout",
content: I18n.t("user.log_out"),
icon: "sign-out-alt"
};
},
_showToggleAnonymousButton() {
return (
(this.siteSettings.allow_anonymous_posting &&
this.currentUser.trust_level >=
this.siteSettings.anonymous_posting_min_trust_level) ||
this.currentUser.is_anonymous
);
}
});

View File

@ -3,6 +3,17 @@ import { h } from "virtual-dom";
import { formatUsername } from "discourse/lib/utilities";
import hbs from "discourse/widgets/hbs-compiler";
const UserMenuAction = {
QUICK_ACCESS: "quickAccess"
};
const QuickAccess = {
BOOKMARKS: "bookmarks",
MESSAGES: "messages",
NOTIFICATIONS: "notifications",
PROFILE: "profile"
};
let extraGlyphs;
export function addUserMenuGlyph(glyph) {
@ -15,6 +26,8 @@ createWidget("user-menu-links", {
profileLink() {
const link = {
action: UserMenuAction.QUICK_ACCESS,
actionParam: QuickAccess.PROFILE,
route: "user",
model: this.currentUser,
className: "user-activity-link",
@ -30,8 +43,21 @@ createWidget("user-menu-links", {
return link;
},
notificationsGlyph() {
return {
label: "user.notifications",
className: "user-notifications-link",
icon: "bell",
href: `${this.attrs.path}/notifications`,
action: UserMenuAction.QUICK_ACCESS,
actionParam: QuickAccess.NOTIFICATIONS
};
},
bookmarksGlyph() {
return {
action: UserMenuAction.QUICK_ACCESS,
actionParam: QuickAccess.BOOKMARKS,
label: "user.bookmarks",
className: "user-bookmarks-link",
icon: "bookmark",
@ -41,6 +67,8 @@ createWidget("user-menu-links", {
messagesGlyph() {
return {
action: UserMenuAction.QUICK_ACCESS,
actionParam: QuickAccess.MESSAGES,
label: "user.private_messages",
className: "user-pms-link",
icon: "envelope",
@ -49,24 +77,20 @@ createWidget("user-menu-links", {
},
linkHtml(link) {
if (this.isActive(link)) {
link = this.markAsActive(link);
}
return this.attach("link", link);
},
glyphHtml(glyph) {
if (this.isActive(glyph)) {
glyph = this.markAsActive(glyph);
}
return this.attach("link", $.extend(glyph, { hideLabel: true }));
},
html(attrs) {
const { currentUser, siteSettings } = this;
const isAnon = currentUser.is_anonymous;
const allowAnon =
(siteSettings.allow_anonymous_posting &&
currentUser.trust_level >=
siteSettings.anonymous_posting_min_trust_level) ||
isAnon;
const path = attrs.path;
html() {
const links = [this.profileLink()];
const glyphs = [];
@ -81,42 +105,39 @@ createWidget("user-menu-links", {
});
}
glyphs.push(this.notificationsGlyph());
glyphs.push(this.bookmarksGlyph());
if (siteSettings.enable_personal_messages) {
if (this.siteSettings.enable_personal_messages) {
glyphs.push(this.messagesGlyph());
}
if (allowAnon) {
if (!isAnon) {
glyphs.push({
action: "toggleAnonymous",
label: "switch_to_anon",
className: "enable-anonymous",
icon: "user-secret"
});
} else {
glyphs.push({
action: "toggleAnonymous",
label: "switch_from_anon",
className: "disable-anonymous",
icon: "ban"
});
}
}
// preferences always goes last
glyphs.push({
label: "user.preferences",
className: "user-preferences-link",
icon: "cog",
href: `${path}/preferences`
});
return h("ul.menu-links-row", [
links.map(l => h("li.user", this.linkHtml(l))),
h("li.glyphs", glyphs.map(l => this.glyphHtml(l)))
]);
},
markAsActive(definition) {
// Clicking on an active quick access tab icon should redirect the user to
// the full page.
definition.action = null;
definition.actionParam = null;
if (definition.className) {
definition.className += " active";
} else {
definition.className = "active";
}
return definition;
},
isActive({ action, actionParam }) {
return (
action === UserMenuAction.QUICK_ACCESS &&
actionParam === this.attrs.currentQuickAccess
);
}
});
@ -148,6 +169,7 @@ export default createWidget("user-menu", {
defaultState() {
return {
currentQuickAccess: QuickAccess.NOTIFICATIONS,
hasUnread: false,
markUnread: null
};
@ -155,37 +177,18 @@ export default createWidget("user-menu", {
panelContents() {
const path = this.currentUser.get("path");
const { currentQuickAccess } = this.state;
let result = [
this.attach("user-menu-links", { path }),
this.attach("user-notifications", { path })
const result = [
this.attach("user-menu-links", {
path,
currentQuickAccess
}),
this.quickAccessPanel(path)
];
if (this.settings.showLogoutButton || this.state.hasUnread) {
result.push(h("hr.bottom-area"));
}
if (this.settings.showLogoutButton) {
result.push(
h("div.logout-link", [
h(
"ul.menu-links",
h(
"li",
this.attach("link", {
action: "logout",
className: "logout",
icon: "sign-out-alt",
href: "",
label: "user.log_out"
})
)
)
])
);
}
if (this.state.hasUnread) {
result.push(h("hr.bottom-area"));
result.push(this.attach("user-menu-dismiss-link"));
}
@ -196,8 +199,8 @@ export default createWidget("user-menu", {
return this.state.markRead();
},
notificationsLoaded({ notifications, markRead }) {
this.state.hasUnread = notifications.filterBy("read", false).length > 0;
itemsLoaded({ hasUnread, markRead }) {
this.state.hasUnread = hasUnread;
this.state.markRead = markRead;
},
@ -234,5 +237,20 @@ export default createWidget("user-menu", {
} else {
this.sendWidgetAction("toggleUserMenu");
}
},
quickAccess(type) {
if (this.state.currentQuickAccess !== type) {
this.state.currentQuickAccess = type;
}
},
quickAccessPanel(path) {
const { showLogoutButton } = this.settings;
// This deliberately does NOT fallback to a default quick access panel.
return this.attach(`quick-access-${this.state.currentQuickAccess}`, {
path,
showLogoutButton
});
}
});

View File

@ -1,131 +0,0 @@
import { createWidget } from "discourse/widgets/widget";
import { headerHeight } from "discourse/components/site-header";
import { h } from "virtual-dom";
import DiscourseURL from "discourse/lib/url";
import { ajax } from "discourse/lib/ajax";
export default createWidget("user-notifications", {
tagName: "div.notifications",
buildKey: () => "user-notifications",
defaultState() {
return { notifications: [], loading: false, loaded: false };
},
markRead() {
ajax("/notifications/mark-read", { method: "PUT" }).then(() => {
this.refreshNotifications(this.state);
});
},
refreshNotifications(state) {
if (this.loading) {
return;
}
// estimate (poorly) the amount of notifications to return
let limit = Math.round(($(window).height() - headerHeight()) / 55);
// we REALLY don't want to be asking for negative counts of notifications
// less than 5 is also not that useful
if (limit < 5) {
limit = 5;
}
if (limit > 40) {
limit = 40;
}
const silent = this.currentUser.get("enforcedSecondFactor");
const stale = this.store.findStale(
"notification",
{ recent: true, silent, limit },
{ cacheKey: "recent-notifications" }
);
if (stale.hasResults) {
const results = stale.results;
let content = results.get("content");
// we have to truncate to limit, otherwise we will render too much
if (content && content.length > limit) {
content = content.splice(0, limit);
results.set("content", content);
results.set("totalRows", limit);
}
state.notifications = results;
} else {
state.loading = true;
}
stale
.refresh()
.then(notifications => {
if (!silent) {
this.currentUser.set("unread_notifications", 0);
}
state.notifications = notifications;
})
.catch(() => {
state.notifications = [];
})
.finally(() => {
state.loading = false;
state.loaded = true;
this.sendWidgetAction("notificationsLoaded", {
notifications: state.notifications,
markRead: () => this.markRead()
});
this.scheduleRerender();
});
},
html(attrs, state) {
if (!state.loaded) {
this.refreshNotifications(state);
}
const result = [];
if (state.loading) {
result.push(h("div.spinner-container", h("div.spinner")));
} else if (state.notifications.length) {
const notificationItems = state.notifications.map(notificationAttrs => {
const notificationName = this.site.notificationLookup[
notificationAttrs.notification_type
];
return this.attach(
`${notificationName.dasherize()}-notification-item`,
notificationAttrs,
{},
{ fallbackWidgetName: "default-notification-item" }
);
});
result.push(h("hr"));
const items = [notificationItems];
if (notificationItems.length > 5) {
items.push(
h(
"li.read.last.heading.show-all",
this.attach("button", {
title: "notifications.more",
icon: "chevron-down",
action: "showAllNotifications",
className: "btn"
})
)
);
}
result.push(h("ul", items));
}
return result;
},
showAllNotifications() {
DiscourseURL.routeTo(`${this.attrs.path}/notifications`);
}
});

View File

@ -116,7 +116,15 @@ I18n.interpolate = function(message, options) {
for (var i = 0; placeholder = matches[i]; i++) {
name = placeholder.replace(this.PLACEHOLDER, "$1");
value = options[name];
if (typeof options[name] === "string") {
// The dollar sign (`$`) is a special replace pattern, and `$&` inserts
// the matched string. Thus dollars signs need to be escaped with the
// special pattern `$$`, which inserts a single `$`.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#Specifying_a_string_as_a_parameter
value = options[name].replace(/\$/g, "$$$$");
} else {
value = options[name];
}
if (!this.isValidNode(options, name)) {
value = "[missing " + placeholder + " value]";

View File

@ -167,6 +167,7 @@ const DEFAULT_LIST = [
"iframe[marginheight]",
"iframe[marginwidth]",
"iframe[width]",
"iframe[allowfullscreen]",
"img[alt]",
"img[height]",
"img[title]",

View File

@ -84,9 +84,9 @@ export default SelectKitRowComponent.extend({
},
@computed("category.description_text")
descriptionText(description) {
if (description) {
return this._formatCategoryDescription(description);
descriptionText(descriptionText) {
if (descriptionText) {
return this._formatCategoryDescription(descriptionText);
}
},

View File

@ -53,6 +53,9 @@
&:hover {
color: $primary-medium;
background: $primary-low;
.d-icon {
color: $primary-medium;
}
}
}
}

View File

@ -626,10 +626,14 @@
flex: 1 0 auto;
white-space: nowrap;
}
h4 {
h4,
.sha-link {
font-size: $font-down-2;
margin-bottom: 0;
}
.sha-link {
font-weight: normal;
}
}
.version-status {
display: flex;

View File

@ -90,7 +90,7 @@
color: $danger;
}
.desc {
color: dark-light-choose($primary-medium, $secondary-medium);
color: $primary-medium;
}
h3 {
font-size: $font-0;

View File

@ -169,12 +169,23 @@
font-style: italic;
}
.whisper {
margin-right: 0.25em;
}
.display-edit-reason {
display: inline;
display: inline-flex;
a {
display: inline-flex;
}
.d-icon {
padding: 0.3em 0.5em;
color: $tertiary;
}
}
#edit-reason {
margin: 4px;
margin: 0 4px;
}
.user-selector,

View File

@ -147,7 +147,7 @@
}
.user-menu {
.notifications {
.quick-access-panel {
width: 100%;
display: table;
@ -187,6 +187,11 @@
padding: 0;
> div {
overflow: hidden; // clears the text from wrapping below icons
// Truncate items with more than 2 lines.
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
@ -223,9 +228,12 @@
border-width: 2px;
margin: 0 auto;
}
.show-all .btn {
.show-all a {
width: 100%;
padding: 2px 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 30px;
color: dark-light-choose($primary-medium, $secondary-high);
background: blend-primary-secondary(5%);
&:hover {
@ -237,29 +245,24 @@
@include unselectable;
}
.logout-link,
.dismiss-link {
display: inline-block;
}
.dismiss-link {
float: right;
}
}
.notifications .logout {
padding: 0.25em;
&:hover {
background-color: $highlight-medium;
}
}
div.menu-links-header {
width: 100%;
display: table;
border-collapse: separate;
border-spacing: 0 0.5em;
.menu-links-row {
border-bottom: 1px solid dark-light-choose($primary-low, $secondary-medium);
display: flex;
// Tabs should have "ears".
padding: 0 4px;
li {
display: inline-flex;
align-items: center;
@ -271,6 +274,42 @@ div.menu-links-header {
flex-wrap: wrap;
text-align: right;
max-width: 65%; //IE11
a {
// Expand the click area a bit.
padding-left: 0.6em;
padding-right: 0.6em;
}
}
a {
// This is to make sure active and inactive tab icons have the same
// size. `box-sizing` does not work and I have no idea why.
border: 1px solid transparent;
border-bottom: 0;
}
a.active {
border: 1px solid dark-light-choose($primary-low, $secondary-medium);
border-bottom: 0;
position: relative;
&:after {
display: block;
position: absolute;
top: 100%;
left: 0;
z-index: z("header") + 1; // Higher than .menu-panel
width: 100%;
height: 0;
content: "";
border-top: 1px solid $secondary;
}
&:focus,
&:hover {
background-color: inherit;
}
}
}
}
@ -283,12 +322,24 @@ div.menu-links-header {
padding: 0.3em 0.5em;
}
a.user-activity-link {
max-width: 150px;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
align-items: center;
display: flex;
margin: -0.5em 0;
max-width: 130px;
// `overflow: hidden` on `.user-activity-link` would hide the `::after`
// pseudo element (used to create the tab-looking effect). Sets `overflow:
// hidden` on the child username label instead.
overflow: visible;
span.d-label {
display: block;
max-width: 130px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@include breakpoint(mobile-medium) {
max-width: 125px;
}
@ -311,6 +362,6 @@ div.menu-links-header {
}
.d-icon-user {
margin-right: 0.2em;
margin-right: 0.475em;
}
}

View File

@ -464,8 +464,9 @@ aside.onebox.twitterstatus .onebox-body {
}
}
// Onebox - Imgur - Album
.onebox.imgur-album {
// Onebox - Imgur/Flickr - Album
.onebox.imgur-album,
.onebox.flickr-album {
.outer-box {
position: absolute;
z-index: z("base");

View File

@ -262,10 +262,6 @@
.reviewable-action,
.reviewable-action-dropdown {
margin-right: 0.5em;
&.delete-user {
@extend .btn-danger;
}
}
}
padding-bottom: 1em;

View File

@ -23,28 +23,29 @@
animation-name: button-jump-up;
width: 145px;
text-align: center;
position: relative;
margin-bottom: 0px;
position: absolute;
right: 0;
top: -2em;
.btn {
margin: 0;
}
}
#topic-progress-wrapper {
display: flex;
.topic-admin-menu-button-container {
position: absolute;
bottom: 0px;
left: -38px;
width: 0px;
.widget-button {
height: 35px;
border-right: 1px solid dark-light-diff($primary, $secondary, 80%, -70%);
display: flex;
> span {
display: flex;
}
}
.topic-admin-popup-menu.right-side {
position: relative;
right: 50px;
position: absolute;
bottom: 0;
right: 0;
left: auto;
transition: bottom 0.5s;
transform: translateZ(
0
); // iOS11 Rendering bug https://meta.discourse.org/t/wrench-menu-not-disappearing-on-ios/94297

View File

@ -78,6 +78,10 @@ body.footer-nav-ipad {
padding-bottom: 0; // resets safe-area-inset-bottom
}
#reply-control.fullscreen {
z-index: z("ipad-header-nav") + 1;
}
&.docked .d-header {
margin-top: $footer-nav-height;
}

View File

@ -241,6 +241,9 @@ a.toggle-preview {
&.fullscreen {
// important needed because of inline styles when height is changed manually with grippie
height: 100vh !important;
@supports (--custom: property) {
height: calc(var(--composer-vh, 1vh) * 100) !important;
}
z-index: z("header") + 1;
.d-editor-preview-wrapper {
margin-top: 1%;
@ -279,3 +282,9 @@ a.toggle-preview {
}
}
}
.fullscreen-composer.keyboard-visible {
#reply-control.fullscreen {
top: 0px;
}
}

View File

@ -196,10 +196,10 @@ div.lightbox-wrapper {
}
}
.topic-details-flex {
.topic-column-wrapper {
display: flex;
.topic-details-column-1 {
.topic-column.details-column {
flex-direction: column;
width: 80%;
@ -225,7 +225,7 @@ div.lightbox-wrapper {
}
}
.topic-details-column-2 {
.topic-column.featured-image-column {
.topic-featured-image img {
max-width: 200px;
max-height: 100px;

View File

@ -21,6 +21,14 @@
height: 250px;
&.edit-title {
height: 100%;
height: calc(var(--composer-vh, 1vh) * 100);
}
}
html.keyboard-visible &.open {
height: calc(var(--composer-vh, 1vh) * 100);
.reply-area {
padding-bottom: 0px;
}
}

View File

@ -50,17 +50,12 @@
#topic-progress-wrapper {
position: fixed;
width: 0;
right: 0;
bottom: 0;
z-index: z("timeline");
margin-right: 148px;
&:not(.docked) {
margin-bottom: env(safe-area-inset-bottom);
}
.topic-admin-menu-button-container .toggle-admin-menu {
height: 43px;
}
}
#topic-progress-expanded {

View File

@ -64,7 +64,7 @@ class ApplicationController < ActionController::Base
after_action :remember_theme_id
def remember_theme_id
if @theme_ids.present?
if @theme_ids.present? && request.format == "html"
Stylesheet::Watcher.theme_id = @theme_ids.first if defined? Stylesheet::Watcher
end
end

View File

@ -8,7 +8,7 @@ class EmbedController < ApplicationController
skip_before_action :check_xhr, :preload_json, :verify_authenticity_token
before_action :ensure_embeddable, except: [ :info, :topics ]
before_action :get_embeddable_css_class, except: [ :info, :topics ]
before_action :prepare_embeddable, except: [ :info ]
before_action :ensure_api_request, only: [ :info ]
layout 'embed'
@ -123,10 +123,13 @@ class EmbedController < ApplicationController
private
def get_embeddable_css_class
def prepare_embeddable
@embeddable_css_class = ""
embeddable_host = EmbeddableHost.record_for_url(request.referer)
@embeddable_css_class = " class=\"#{embeddable_host.class_name}\"" if embeddable_host.present? && embeddable_host.class_name.present?
@data_referer = request.referer
@data_referer = '*' if SiteSetting.embed_any_origin? && @data_referer.blank?
end
def ensure_api_request

View File

@ -47,11 +47,16 @@ class MetadataController < ApplicationController
}
logo = SiteSetting.site_manifest_icon_url
manifest[:icons] << {
src: UrlHelper.absolute(logo),
sizes: "512x512",
type: MiniMime.lookup_by_filename(logo)&.content_type || "image/png"
} if logo
if logo
icon_entry = {
src: UrlHelper.absolute(logo),
sizes: "512x512",
type: MiniMime.lookup_by_filename(logo)&.content_type || "image/png"
}
manifest[:icons] << icon_entry.dup
icon_entry[:purpose] = "maskable"
manifest[:icons] << icon_entry
end
manifest[:short_name] = SiteSetting.short_title if SiteSetting.short_title.present?

View File

@ -4,15 +4,20 @@ class PostReadersController < ApplicationController
requires_login
def index
post = Post.includes(topic: %i[allowed_groups]).find(params[:id])
read_state = post.topic.allowed_groups.any? { |g| g.publish_read_state? && g.users.include?(current_user) }
raise Discourse::InvalidAccess unless read_state
post = Post.includes(topic: %i[topic_allowed_groups topic_allowed_users]).find(params[:id])
ensure_can_see_readers!(post)
readers = User
.where(staged: false)
.where.not(id: post.user_id)
.joins(:topic_users)
.where.not(topic_users: { last_read_post_number: nil })
.where('topic_users.topic_id = ? AND topic_users.last_read_post_number >= ?', post.topic_id, post.post_number)
.where.not(id: [current_user.id, post.user_id])
if post.whisper?
non_group_members = post.topic.topic_allowed_users.map(&:user_id)
readers = readers.where.not(id: non_group_members)
end
readers = readers.map do |r|
{
@ -24,4 +29,15 @@ class PostReadersController < ApplicationController
render_json_dump(post_readers: readers)
end
private
def ensure_can_see_readers!(post)
show_readers = GroupUser
.where(user: current_user)
.joins(:group)
.where(groups: { id: post.topic.topic_allowed_groups.map(&:group_id), publish_read_state: true }).exists?
raise Discourse::InvalidAccess unless show_readers
end
end

View File

@ -4,19 +4,20 @@ class UserActionsController < ApplicationController
def index
params.require(:username)
params.permit(:filter, :offset, :acting_username)
params.permit(:filter, :offset, :acting_username, :limit)
user = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts))
raise Discourse::NotFound unless guardian.can_see_profile?(user)
offset = [0, params[:offset].to_i].max
action_types = (params[:filter] || "").split(",").map(&:to_i)
limit = params.fetch(:limit, 30).to_i
opts = {
user_id: user.id,
user: user,
offset: offset,
limit: 30,
limit: limit,
action_types: action_types,
guardian: guardian,
ignore_private_messages: params[:filter] ? false : true,

View File

@ -141,6 +141,10 @@ class UsersController < ApplicationController
def username
params.require(:new_username)
if clashing_with_existing_route?(params[:new_username]) || User.reserved_username?(params[:new_username])
return render_json_error(I18n.t("login.reserved_username"))
end
user = fetch_user_from_params
guardian.ensure_can_edit_username!(user)
@ -359,7 +363,7 @@ class UsersController < ApplicationController
return fail_with("login.email_too_long")
end
if User.reserved_username?(params[:username])
if clashing_with_existing_route?(params[:username]) || User.reserved_username?(params[:username])
return fail_with("login.reserved_username")
end
@ -1355,4 +1359,18 @@ class UsersController < ApplicationController
end
end
def clashing_with_existing_route?(username)
normalized_username = User.normalize_username(username)
http_verbs = %w[GET POST PUT DELETE PATCH]
allowed_actions = %w[show update destroy]
http_verbs.any? do |verb|
begin
path = Rails.application.routes.recognize_path("/u/#{normalized_username}", method: verb)
allowed_actions.exclude?(path[:action])
rescue ActionController::RoutingError
false
end
end
end
end

View File

@ -384,7 +384,7 @@ module ApplicationHelper
return "" if erbs.blank?
result = +""
erbs.each { |erb| result << render(file: erb) }
erbs.each { |erb| result << render(inline: File.read(erb)) }
result.html_safe
end
@ -437,17 +437,14 @@ module ApplicationHelper
def theme_lookup(name)
Theme.lookup_field(theme_ids, mobile_view? ? :mobile : :desktop, name)
&.html_safe
end
def theme_translations_lookup
Theme.lookup_field(theme_ids, :translations, I18n.locale)
&.html_safe
end
def theme_js_lookup
Theme.lookup_field(theme_ids, :extra_js, nil)
&.html_safe
end
def discourse_stylesheet_link_tag(name, opts = {})

View File

@ -22,7 +22,7 @@ module Jobs
.where(staged: false)
.find_each do |user|
next unless user.email_confirmed?
group.add(user)
group.add(user, automatic: true)
GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(user)
end

View File

@ -148,7 +148,7 @@ module Jobs
if start_raw == post.raw && raw != post.raw
changes = { raw: raw, edit_reason: I18n.t("upload.edit_reason") }
post.revise(Discourse.system_user, changes, bypass_bump: true)
post.revise(Discourse.system_user, changes, bypass_bump: true, skip_staff_log: true)
elsif has_downloaded_image || has_new_large_image || has_new_broken_image
post.trigger_post_process(bypass_bump: true)
end

View File

@ -1,7 +1,5 @@
# frozen_string_literal: true
require_dependency 'flag_query'
module Jobs
class PendingReviewablesReminder < Jobs::Scheduled

View File

@ -34,7 +34,7 @@ module Jobs
if SiteSetting.pop3_polling_ssl
if SiteSetting.pop3_polling_openssl_verify
pop3.enable_ssl
pop3.enable_ssl(max_version: OpenSSL::SSL::TLS1_2_VERSION)
else
pop3.enable_ssl(OpenSSL::SSL::VERIFY_NONE)
end

View File

@ -3,15 +3,33 @@
class Jobs::ReviewablePriorities < Jobs::Scheduled
every 1.day
def execute(args)
# We need this many reviewables before we'll calculate priorities
def self.min_reviewables
15
end
# We calculate the percentiles here for medium and high. Low is always 0 (all)
res = DB.query_single(<<~SQL)
# We want to look at scores for items with this many reviewables (flags) attached
def self.target_count
2
end
def execute(args)
return unless Reviewable.where('score > 0').count >= self.class.min_reviewables
res = DB.query_single(<<~SQL, target_count: self.class.target_count)
SELECT COALESCE(PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY score), 0.0) AS medium,
COALESCE(PERCENTILE_DISC(0.85) WITHIN GROUP (ORDER BY score), 0.0) AS high
FROM reviewables
FROM (
SELECT r.score
FROM reviewables AS r
INNER JOIN reviewable_scores AS rs ON rs.reviewable_id = r.id
GROUP BY r.id
HAVING COUNT(*) >= :target_count
) AS x
SQL
return unless res && res.size == 2
medium, high = res
Reviewable.set_priorities(medium: medium, high: high)

View File

@ -261,7 +261,8 @@ class Category < ActiveRecord::Base
@@cache ||= LruRedux::ThreadSafeCache.new(1000)
@@cache.getset(self.description) do
Nokogiri::HTML.fragment(self.description).text.strip.html_safe
text = Nokogiri::HTML.fragment(self.description).text.strip
Rack::Utils.escape_html(text).html_safe
end
end

View File

@ -65,7 +65,8 @@ class DirectoryItem < ActiveRecord::Base
0
FROM users u
LEFT JOIN directory_items di ON di.user_id = u.id AND di.period_type = :period_type
WHERE di.id IS NULL AND u.id > 0 AND u.silenced_till IS NULL and u.active
WHERE di.id IS NULL AND u.id > 0 AND u.silenced_till IS NULL AND u.active
#{SiteSetting.must_approve_users ? 'AND u.approved' : ''}
", period_type: period_types[period_type]
# Calculate new values and update records

View File

@ -613,7 +613,7 @@ class Group < ActiveRecord::Base
PUBLISH_CATEGORIES_LIMIT = 10
def add(user, notify: false)
def add(user, notify: false, automatic: false)
self.users.push(user) unless self.users.include?(user)
if notify
@ -635,12 +635,15 @@ class Group < ActiveRecord::Base
Discourse.request_refresh!(user_ids: [user.id])
end
DiscourseEvent.trigger(:user_added_to_group, user, self, automatic: automatic)
self
end
def remove(user)
self.group_users.where(user: user).each(&:destroy)
user.update_columns(primary_group_id: nil) if user.primary_group_id == self.id
DiscourseEvent.trigger(:user_removed_from_group, user, self)
end
def add_owner(user)

View File

@ -116,10 +116,19 @@ class Post < ActiveRecord::Base
}
scope :have_uploads, -> {
where(
"(posts.cooked LIKE '%<a %' OR posts.cooked LIKE '%<img %') AND (posts.cooked LIKE ? OR posts.cooked LIKE '%/original/%' OR posts.cooked LIKE '%/optimized/%' OR posts.cooked LIKE '%data-orig-src=%')",
"%/uploads/#{RailsMultisite::ConnectionManagement.current_db}/%"
)
where("
(
posts.cooked LIKE '%<a %' OR
posts.cooked LIKE '%<img %' OR
posts.cooked LIKE '%<video %'
) AND (
posts.cooked LIKE ? OR
posts.cooked LIKE '%/original/%' OR
posts.cooked LIKE '%/optimized/%' OR
posts.cooked LIKE '%data-orig-src=%' OR
posts.cooked LIKE '%/uploads/short-url/%'
)", "%/uploads/#{RailsMultisite::ConnectionManagement.current_db}/%"
)
}
delegate :username, to: :user
@ -170,6 +179,11 @@ class Post < ActiveRecord::Base
end
end
def readers_count
read_count = reads - 1 # Excludes poster
read_count < 0 ? 0 : read_count
end
def publish_change_to_clients!(type, opts = {})
# special failsafe for posts missing topics consistency checks should fix,
# but message is safe to skip
@ -913,10 +927,14 @@ class Post < ActiveRecord::Base
sha1 = Upload.sha1_from_short_url(src)
yield(src, nil, sha1)
next
elsif src.include?("/uploads/short-url/")
sha1 = Upload.sha1_from_short_path(src)
yield(src, nil, sha1)
next
end
next if upload_patterns.none? { |pattern| src =~ pattern }
next if Rails.configuration.multisite && src.exclude?(current_db) && src.exclude?("short-url")
next if Rails.configuration.multisite && src.exclude?(current_db)
src = "#{SiteSetting.force_https ? "https" : "http"}:#{src}" if src.start_with?("//")
next unless Discourse.store.has_been_uploaded?(src) || (include_local_upload && src =~ /\A\/[^\/]/i)

View File

@ -9,10 +9,11 @@ class PostAnalyzer
@raw = raw
@topic_id = topic_id
@onebox_urls = []
@found_oneboxes = false
end
def found_oneboxes?
@onebox_urls.present?
@found_oneboxes
end
def has_oneboxes?
@ -36,7 +37,9 @@ class PostAnalyzer
result = Oneboxer.apply(cooked) do |url|
@onebox_urls << url
Oneboxer.invalidate(url) if opts[:invalidate_oneboxes]
Oneboxer.cached_onebox(url)
onebox = Oneboxer.cached_onebox(url)
@found_oneboxes = true if onebox.present?
onebox
end
cooked = result.to_html if result.changed?
@ -126,7 +129,7 @@ class PostAnalyzer
# How many links are present in the post
def link_count
raw_links.size
raw_links.size + @onebox_urls.size
end
def cooked_stripped

View File

@ -78,6 +78,12 @@ class Reviewable < ActiveRecord::Base
)
end
# This number comes from looking at forums in the wild and what numbers work.
# As the site accumulates real data it'll be based on the site activity instead.
def self.typical_sensitivity
12.5
end
# Generate `pending?`, `rejected?`, etc helper methods
statuses.each do |name, id|
define_method("#{name}?") { status == id }
@ -195,11 +201,13 @@ class Reviewable < ActiveRecord::Base
return Float::MAX if sensitivity == 0
ratio = sensitivity / Reviewable.sensitivity[:low].to_f
high = PluginStore.get('reviewables', "priority_#{Reviewable.priorities[:high]}")
return (10.0 * scale) if high.nil?
high = (
PluginStore.get('reviewables', "priority_#{Reviewable.priorities[:high]}") ||
typical_sensitivity
).to_f
# We want this to be hard to reach
(high.to_f * ratio) * scale
((high.to_f * ratio) * scale).truncate(2)
end
def self.sensitivity_score(sensitivity, scale: 1.0)

View File

@ -269,10 +269,11 @@ protected
end
end
def build_action(actions, id, icon:, bundle: nil, client_action: nil, confirm: false)
def build_action(actions, id, icon:, button_class: nil, bundle: nil, client_action: nil, confirm: false)
actions.add(id, bundle: bundle) do |action|
prefix = "reviewables.actions.#{id}"
action.icon = icon
action.button_class = button_class
action.label = "#{prefix}.title"
action.description = "#{prefix}.description"
action.client_action = client_action

View File

@ -38,6 +38,7 @@ class ReviewableQueuedPost < Reviewable
if pending? && guardian.can_delete_user?(created_by)
actions.add(:delete_user) do |action|
action.icon = 'trash-alt'
action.button_class = 'btn-danger'
action.label = 'reviewables.actions.delete_user.title'
action.confirm_message = 'reviewables.actions.delete_user.confirm'
end

View File

@ -59,19 +59,14 @@ class ReviewableScore < ActiveRecord::Base
user_stat = user&.user_stat
return 0.0 if user_stat.blank?
calc_user_accuracy_bonus(
user_stat.flags_agreed,
user_stat.flags_disagreed,
user_stat.flags_ignored
)
calc_user_accuracy_bonus(user_stat.flags_agreed, user_stat.flags_disagreed)
end
def self.calc_user_accuracy_bonus(agreed, disagreed, ignored)
def self.calc_user_accuracy_bonus(agreed, disagreed)
agreed ||= 0
disagreed ||= 0
ignored ||= 0
total = (agreed + disagreed + ignored).to_f
total = (agreed + disagreed).to_f
return 0.0 if total <= 5
(agreed / total) * 5.0

View File

@ -112,6 +112,7 @@ class SiteSetting < ActiveRecord::Base
end
WATCHED_SETTINGS ||= [
:default_locale,
:attachment_content_type_blacklist,
:attachment_filename_blacklist,
:unicode_username_character_whitelist,

View File

@ -264,6 +264,7 @@ class Theme < ActiveRecord::Base
if with_scheme
targets.prepend(:desktop, :mobile, :admin)
targets.append(*Discourse.find_plugin_css_assets(mobile_view: true, desktop_view: true))
Stylesheet::Manager.cache.clear if clear_manager_cache
end

View File

@ -138,7 +138,7 @@ class ThemeField < ActiveRecord::Base
when "hbs"
js_compiler.append_ember_template(filename.sub("discourse/templates/", ""), content)
when "raw.hbs"
js_compiler.append_raw_template(filename, content)
js_compiler.append_raw_template(filename.sub("discourse/templates/", ""), content)
else
raise ThemeJavascriptCompiler::CompileError.new(I18n.t("themes.compile_error.unrecognized_extension", extension: extension))
end
@ -343,7 +343,7 @@ class ThemeField < ActiveRecord::Base
end
def compile_scss
Stylesheet::Compiler.compile("@import \"common/foundation/variables\"; @import \"theme_variables\"; @import \"theme_field\";",
Stylesheet::Compiler.compile("@import \"common/foundation/variables\"; @import \"common/foundation/mixins\"; @import \"theme_variables\"; @import \"theme_field\";",
"theme.scss",
theme_field: self.value.dup,
theme: self.theme

View File

@ -357,7 +357,7 @@ SQL
if topic&.private_message?
groups = read_allowed_groups_of(topic)
post = Post.find_by(topic_id: topic.id, post_number: last_read_post_number)
trigger_post_read_count_update(post, groups)
trigger_post_read_count_update(post, groups, last_read_post_number, user_id)
update_topic_list_read_indicator(topic, groups, last_read_post_number, user_id, false)
end
end
@ -389,8 +389,9 @@ SQL
MessageBus.publish("/private-messages/unread-indicator/#{topic.id}", message, user_ids: groups_to_update.flat_map(&:members))
end
def self.trigger_post_read_count_update(post, groups)
def self.trigger_post_read_count_update(post, groups, last_read_post_number, user_id)
return if groups.empty?
post.publish_change_to_clients!(:read)
opts = { readers_count: post.readers_count, reader_id: user_id }
post.publish_change_to_clients!(:read, opts)
end
end

View File

@ -459,13 +459,6 @@ class PostSerializer < BasicPostSerializer
can_review_topic?
end
def readers_count
read_count = object.reads - 1 # Exclude logged user
read_count -= 1 unless yours
read_count < 0 ? 0 : read_count
end
private
def can_review_topic?

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class ReviewableActionSerializer < ApplicationSerializer
attributes :id, :icon, :label, :confirm_message, :description, :client_action
attributes :id, :icon, :button_class, :label, :confirm_message, :description, :client_action
def label
I18n.t(object.label)

View File

@ -14,11 +14,7 @@ class ReviewableScoreExplanationSerializer < ApplicationSerializer
)
def user_accuracy_bonus
ReviewableScore.calc_user_accuracy_bonus(
object.flags_agreed,
object.flags_disagreed,
object.flags_ignored
)
ReviewableScore.calc_user_accuracy_bonus(object.flags_agreed, object.flags_disagreed)
end
end

View File

@ -6,7 +6,7 @@ class InlineUploads
PLACEHOLDER = "__replace__"
PATH_PLACEHOLDER = "__replace_path__"
UPLOAD_REGEXP_PATTERN = "/original/(\\dX/(?:[a-f0-9]/)*[a-f0-9]{40}[a-zA-Z0-9.]*)"
UPLOAD_REGEXP_PATTERN = "/original/(\\dX/(?:\\h/)*\\h{40}[a-zA-Z0-9.]*)(\\?v=\\d+)?"
private_constant :UPLOAD_REGEXP_PATTERN
def self.process(markdown, on_missing: nil)
@ -26,7 +26,6 @@ class InlineUploads
# Do nothing
elsif !(node.children.count == 1 && (node.children[0].name != "img" && node.children[0].children.blank?)) &&
!(node.name == "a" && node.children.count > 1 && !node_children_names(node).include?("img"))
next
end
@ -34,7 +33,7 @@ class InlineUploads
if (actual_link = (node.attributes["href"]&.value || node.attributes["src"]&.value))
link_occurences << { link: actual_link, is_valid: true }
elsif node.name != "p"
link_occurences << { link: actual_link, is_valid: false }
link_occurences << { link: seen_link, is_valid: false }
end
end
end
@ -64,7 +63,7 @@ class InlineUploads
]
if Discourse.store.external?
regexps << /((https?:)?#{SiteSetting.Upload.s3_base_url}#{UPLOAD_REGEXP_PATTERN})/
regexps << /((?:https?:)?#{SiteSetting.Upload.s3_base_url}#{UPLOAD_REGEXP_PATTERN})/
regexps << /(#{SiteSetting.Upload.s3_cdn_url}#{UPLOAD_REGEXP_PATTERN})/
end
@ -72,8 +71,8 @@ class InlineUploads
indexes = Set.new
markdown.scan(/(\n{2,}|\A)#{regexp}$/) do |match|
if match[1].present?
extension = match[1].split(".")[-1].downcase
if match[1].present? && match[2].present?
extension = match[2].split(".")[-1].downcase
index = $~.offset(2)[0]
indexes << index
if FileHelper.supported_images.include?(extension)
@ -87,36 +86,20 @@ class InlineUploads
markdown.scan(/^#{regexp}(\s)/) do |match|
if match[0].present?
index = $~.offset(0)[0]
next if indexes.include?(index)
indexes << index
raw_matches << [
match[0],
match[0],
+"#{Discourse.base_url}#{PATH_PLACEHOLDER}",
$~.offset(0)[0]
]
next if !indexes.add?(index)
raw_matches << [match[0], match[0], +"#{Discourse.base_url}#{PATH_PLACEHOLDER}", index]
end
end
markdown.scan(/\[[^\[\]]*\]: #{regexp}/) do |match|
if match[0].present?
index = $~.offset(1)[0]
next if indexes.include?(index)
indexes << index
end
indexes.add($~.offset(1)[0]) if match[0].present?
end
markdown.scan(/(([\n\s\)\]\<])+)#{regexp}/) do |match|
if matched_uploads(match[2]).present?
next if indexes.include?($~.offset(3)[0])
raw_matches << [
match[2],
match[2],
+"#{Discourse.base_url}#{PATH_PLACEHOLDER}",
$~.offset(0)[0]
]
next if !indexes.add?($~.offset(3)[0])
index = $~.offset(0)[0]
raw_matches << [match[2], match[2], +"#{Discourse.base_url}#{PATH_PLACEHOLDER}", index]
end
end
end
@ -160,7 +143,7 @@ class InlineUploads
end
end
markdown.scan(/(__([a-f0-9]{40})__)/) do |match|
markdown.scan(/(__(\h{40})__)/) do |match|
upload = Upload.find_by(sha1: match[1])
markdown = markdown.sub(match[0], upload.short_path)
end
@ -182,7 +165,7 @@ class InlineUploads
end
def self.match_bbcode_img(markdown, external_src: false)
markdown.scan(/(\[img\]\s*([^\[\]\s]+)\s*\[\/img\])/) do |match|
markdown.scan(/(\[img\]\s*([^\[\]\s]+)\s*\[\/img\])/i) do |match|
if (matched_uploads(match[1]).present? && block_given?) || external_src
yield(match[0], match[1], +"![](#{PLACEHOLDER})", $~.offset(0)[0])
end
@ -203,7 +186,7 @@ class InlineUploads
end
def self.match_anchor(markdown, external_href: false)
markdown.scan(/((<a[^<]+>)([^<\a>]*?)<\/a>)/) do |match|
markdown.scan(/((<a[^<]+>)([^<\a>]*?)<\/a>)/i) do |match|
node = Nokogiri::HTML::fragment(match[0]).children[0]
href = node.attributes["href"]&.value
@ -219,8 +202,8 @@ class InlineUploads
end
def self.match_img(markdown, external_src: false)
markdown.scan(/(([ ]*)<(?!img)[^<>]+\/?>)?([\r\n]*)(([ ]*)<img ([^>\n]+)>([ ]*))([\r\n]*)/) do |match|
node = Nokogiri::HTML::fragment(match[3].strip).children[0]
markdown.scan(/(<(?!img)[^<>]+\/?>)?(\s*)(<img [^>\n]+>)/i) do |match|
node = Nokogiri::HTML::fragment(match[2].strip).children[0]
src = node.attributes["src"]&.value
if src && (matched_uploads(src).present? || external_src)
@ -229,50 +212,17 @@ class InlineUploads
height = node.attributes["height"]&.value.to_i
title = node.attributes["title"]&.value
text = "#{text}|#{width}x#{height}" if width > 0 && height > 0
after_html_tag = match[0].present?
spaces_before = match[1].present? ? match[1][/ +$/].size : 0
replacement = +"#{" " * spaces_before}![#{text}](#{PLACEHOLDER}#{title.present? ? " \"#{title}\"" : ""})"
spaces_before =
if after_html_tag && !match[0].end_with?("/>")
(match[4].length > 0 ? match[4] : " ")
else
""
end
replacement = +"#{spaces_before}![#{text}](#{PLACEHOLDER}#{title.present? ? " \"#{title}\"" : ""})"
if after_html_tag && (num_newlines = match[2].length) <= 1
replacement.prepend("\n" * (num_newlines == 0 ? 2 : 1))
end
if after_html_tag && !match[0].end_with?("/>") && (num_newlines = match[7].length) <= 1
replacement += ("\n" * (num_newlines == 0 ? 2 : 1))
end
match[3].strip! if !after_html_tag
if (match[1].nil? || match[1].length < 4)
if (match[4].nil? || match[4].length < 4)
yield(match[3], src, replacement, $~.offset(0)[0]) if block_given?
else
yield(match[3], src, match[3].sub(src, PATH_PLACEHOLDER), $~.offset(0)[0]) if block_given?
end
else
yield(match[3], src, match[3].sub(src, PATH_PLACEHOLDER), $~.offset(0)[0]) if block_given?
end
yield(match[2], src, replacement, $~.offset(0)[0]) if block_given?
end
end
end
def self.matched_uploads(node)
matches = []
base_url = Discourse.base_url.sub(/https?:\/\//, "(https?://)")
if GlobalSetting.cdn_url
cdn_url = GlobalSetting.cdn_url.sub(/https?:\/\//, "(https?://)")
end
db = RailsMultisite::ConnectionManagement.current_db
base_url = Discourse.base_url.sub(/https?:\/\//, "(https?://)")
regexps = [
/(upload:\/\/([a-zA-Z0-9]+)[a-zA-Z0-9\.]*)/,
@ -282,7 +232,12 @@ class InlineUploads
/(#{base_url}\/uploads\/#{db}#{UPLOAD_REGEXP_PATTERN})/,
]
regexps << /(#{cdn_url}\/uploads\/#{db}#{UPLOAD_REGEXP_PATTERN})/ if cdn_url
if GlobalSetting.cdn_url && (cdn_url = GlobalSetting.cdn_url.sub(/https?:\/\//, "(https?://)"))
regexps << /(#{cdn_url}\/uploads\/#{db}#{UPLOAD_REGEXP_PATTERN})/
if GlobalSetting.relative_url_root.present?
regexps << /(#{cdn_url}#{GlobalSetting.relative_url_root}\/uploads\/#{db}#{UPLOAD_REGEXP_PATTERN})/
end
end
if Discourse.store.external?
if Rails.configuration.multisite
@ -294,6 +249,7 @@ class InlineUploads
end
end
matches = []
node = node.to_s
regexps.each do |regexp|

View File

@ -4,7 +4,8 @@ class UserAuthenticator
def initialize(user, session, authenticator_finder = Users::OmniauthCallbacksController)
@user = user
@session = session[:authentication]
@session = session
@auth_session = session[:authentication]
@authenticator_finder = authenticator_finder
end
@ -15,7 +16,7 @@ class UserAuthenticator
@user.password_required!
end
@user.skip_email_validation = true if @session && @session[:skip_email_validation].present?
@user.skip_email_validation = true if @auth_session && @auth_session[:skip_email_validation].present?
end
def has_authenticator?
@ -24,18 +25,18 @@ class UserAuthenticator
def finish
if authenticator
authenticator.after_create_account(@user, @session)
authenticator.after_create_account(@user, @auth_session)
confirm_email
end
@session = nil
@session[:authentication] = @auth_session = nil if @auth_session
end
def email_valid?
@session && @session[:email_valid]
@auth_session && @auth_session[:email_valid]
end
def authenticated?
@session && @session[:email]&.downcase == @user.email.downcase && @session[:email_valid].to_s == "true"
@auth_session && @auth_session[:email]&.downcase == @user.email.downcase && @auth_session[:email_valid].to_s == "true"
end
private
@ -54,7 +55,7 @@ class UserAuthenticator
end
def authenticator_name
@session && @session[:authenticator_name]
@auth_session && @auth_session[:authenticator_name]
end
end

View File

@ -12,6 +12,6 @@
<%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %>
<%- end %>
<%- Discourse.find_plugin_css_assets(include_official: allow_plugins?, include_unofficial: allow_third_party_plugins?, mobile_view: mobile_view?).each do |file| %>
<%- Discourse.find_plugin_css_assets(include_official: allow_plugins?, include_unofficial: allow_third_party_plugins?, mobile_view: mobile_view?, desktop_view: !mobile_view?).each do |file| %>
<%= discourse_stylesheet_link_tag(file) %>
<%- end %>

View File

@ -4,8 +4,8 @@
<div class='topic-list-item'>
<%- if @template == "complete" %>
<div class='main-link'>
<div class="topic-details-flex">
<div class='topic-details-column-1'>
<div class="topic-column-wrapper">
<div class='topic-column details-column'>
<div class='topic-title-link'>
<a target="_parent" href="<%= t.url %>" class="title raw-link raw-topic-link" data-topic-id="<%= t.id %>"><%= t.title %></a>
</div>
@ -38,7 +38,7 @@
</div>
</div>
<div class='topic-details-column-2'>
<div class='topic-column featured-image-column'>
<%- if t.image_url.present? %>
<div class='topic-featured-image'>
<img src="<%= t.image_url %>">

View File

@ -2,7 +2,7 @@
<html<%= raw @embeddable_css_class -%>>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover">
<%= discourse_stylesheet_link_tag 'embed', theme_ids: nil %>
<%- unless customization_disabled? %>
<%= discourse_stylesheet_link_tag :embedded_theme %>
@ -13,7 +13,7 @@
<title><%= @topic_view.page_title %> - <%= SiteSetting.title %></title>
<%- end %>
<meta id="data-embedded" data-referer="<%= request.referer %>">
<meta id="data-embedded" data-referer="<%= @data_referer %>">
<%= preload_script 'embed-application' %>
<%= yield :head %>

View File

@ -26,7 +26,7 @@
<div class="dialog">
<p>
<%=t "login.auth_complete" %>
<a href="<%= Discourse.base_url.html_safe %>?authComplete=true"><%= t("login.click_to_continue") %></a>
<a href="<%= Discourse.base_url %>?authComplete=true"><%= t("login.click_to_continue") %></a>
</p>
</div>
</body>

View File

@ -1,5 +1,5 @@
<div class="user-crawler">
<img src='<%= UrlHelper.local_cdn_url(get_absolute_image_url(@user.small_avatar_url)) %>' alt='<%= @user.username %>' title='<%= @user.username %>' />
<img src='<%= ::UrlHelper.local_cdn_url(get_absolute_image_url(@user.small_avatar_url)) %>' alt='<%= @user.username %>' title='<%= @user.username %>' />
<h2 class='username'><%= @user.username %></h2>
</div>

Some files were not shown because too many files have changed in this diff Show More