FEATURE: Inline (Mini) Oneboxing

see:
https://meta.discourse.org/t/mini-inline-onebox-support-rfc/66400?source_topic_id=66066
This commit is contained in:
Robin Ward 2017-07-19 15:08:54 -04:00
parent 44fb2a2833
commit 3882722195
18 changed files with 306 additions and 8 deletions

View File

@ -5,6 +5,7 @@ import { linkSeenCategoryHashtags, fetchUnseenCategoryHashtags } from 'discourse
import { linkSeenTagHashtags, fetchUnseenTagHashtags } from 'discourse/lib/link-tag-hashtag'; import { linkSeenTagHashtags, fetchUnseenTagHashtags } from 'discourse/lib/link-tag-hashtag';
import Composer from 'discourse/models/composer'; import Composer from 'discourse/models/composer';
import { load } from 'pretty-text/oneboxer'; import { load } from 'pretty-text/oneboxer';
import { applyInlineOneboxes } from 'pretty-text/inline-oneboxer';
import { ajax } from 'discourse/lib/ajax'; import { ajax } from 'discourse/lib/ajax';
import InputValidation from 'discourse/models/input-validation'; import InputValidation from 'discourse/models/input-validation';
import { findRawTemplate } from 'discourse/lib/raw-templates'; import { findRawTemplate } from 'discourse/lib/raw-templates';
@ -58,6 +59,8 @@ export default Ember.Component.extend({
@computed @computed
markdownOptions() { markdownOptions() {
return { return {
previewing: true,
lookupAvatarByPostNumber: (postNumber, topicId) => { lookupAvatarByPostNumber: (postNumber, topicId) => {
const topic = this.get('topic'); const topic = this.get('topic');
if (!topic) { return; } if (!topic) { return; }
@ -171,6 +174,10 @@ export default Ember.Component.extend({
}); });
}, },
_loadInlineOneboxes(inline) {
applyInlineOneboxes(inline, ajax);
},
_loadOneboxes($oneboxes) { _loadOneboxes($oneboxes) {
const post = this.get('composer.post'); const post = this.get('composer.post');
let refresh = false; let refresh = false;
@ -572,6 +579,17 @@ export default Ember.Component.extend({
Ember.run.debounce(this, this._loadOneboxes, $oneboxes, 450); Ember.run.debounce(this, this._loadOneboxes, $oneboxes, 450);
} }
let inline = {};
$('a.inline-onebox-loading', $preview).each(function(index, link) {
let $link = $(link);
let text = $link.text();
inline[text] = inline[text] || [];
inline[text].push($link);
});
if (Object.keys(inline).length > 0) {
Ember.run.debounce(this, this._loadInlineOneboxes, inline, 450);
}
this.trigger('previewRefreshed', $preview); this.trigger('previewRefreshed', $preview);
this.sendAction('afterRefresh', $preview); this.sendAction('afterRefresh', $preview);
}, },

View File

@ -4,6 +4,7 @@
//= require ./pretty-text/engines/discourse-markdown/quotes //= require ./pretty-text/engines/discourse-markdown/quotes
//= require ./pretty-text/engines/discourse-markdown/emoji //= require ./pretty-text/engines/discourse-markdown/emoji
//= require ./pretty-text/engines/discourse-markdown/onebox //= require ./pretty-text/engines/discourse-markdown/onebox
//= require ./pretty-text/engines/discourse-markdown/inline-onebox
//= require ./pretty-text/engines/discourse-markdown/bbcode-block //= require ./pretty-text/engines/discourse-markdown/bbcode-block
//= require ./pretty-text/engines/discourse-markdown/bbcode-inline //= require ./pretty-text/engines/discourse-markdown/bbcode-inline
//= require ./pretty-text/engines/discourse-markdown/code //= require ./pretty-text/engines/discourse-markdown/code

View File

@ -9,3 +9,4 @@
//= require ./pretty-text/white-lister //= require ./pretty-text/white-lister
//= require ./pretty-text/sanitizer //= require ./pretty-text/sanitizer
//= require ./pretty-text/oneboxer //= require ./pretty-text/oneboxer
//= require ./pretty-text/inline-oneboxer

View File

@ -0,0 +1,67 @@
import { cachedInlineOnebox } from 'pretty-text/inline-oneboxer';
function applyInlineOnebox(state, silent) {
if (silent || !state.tokens) {
return;
}
for (let i=1; i<state.tokens.length; i++) {
let token = state.tokens[i];
if (token.type === "inline") {
let children = token.children;
for (let j=0; j<children.length-2; j++) {
let child = children[j];
if (child.type === "link_open" && child.markup === 'linkify' && child.info === 'auto') {
if (j > children.length-3) {
continue;
}
let text = children[j+1];
let close = children[j+2];
// check attrs only include a href
let attrs = child.attrs;
if (!attrs || attrs.length !== 1 || attrs[0][0] !== "href") {
continue;
}
let href = attrs[0][1];
if (!/^http|^\/\//i.test(href)) {
continue;
}
// we already know text matches cause it is an auto link
if (!close || close.type !== "link_close") {
continue;
}
// link must be the same as the href
if (!text || text.content !== href) {
continue;
}
// check for href
let onebox = cachedInlineOnebox(href);
let options = state.md.options.discourse;
if (options.lookupInlineOnebox) {
onebox = options.lookupInlineOnebox(href);
}
if (onebox) {
text.content = onebox.title;
} else if (state.md.options.discourse.previewing) {
attrs.push(["class", "inline-onebox-loading"]);
}
}
}
}
}
}
export function setup(helper) {
helper.registerPlugin(md => {
md.core.ruler.after('linkify', 'inline-onebox', applyInlineOnebox);
});
}

View File

@ -0,0 +1,19 @@
let _cache = {};
export function applyInlineOneboxes(inline, ajax) {
return ajax("/inline-onebox", {
data: { urls: Object.keys(inline) },
}).then(result => {
result['inline-oneboxes'].forEach(onebox => {
_cache[onebox.url] = onebox;
let links = inline[onebox.url] || [];
links.forEach(link => {
link.text(onebox.title);
});
});
});
};
export function cachedInlineOnebox(url) {
return _cache[url];
}

View File

@ -19,7 +19,9 @@ export function buildOptions(state) {
getCurrentUser, getCurrentUser,
currentUser, currentUser,
lookupAvatarByPostNumber, lookupAvatarByPostNumber,
emojiUnicodeReplacer emojiUnicodeReplacer,
lookupInlineOnebox,
previewing
} = state; } = state;
let features = { let features = {
@ -52,8 +54,10 @@ export function buildOptions(state) {
lookupAvatarByPostNumber, lookupAvatarByPostNumber,
mentionLookup: state.mentionLookup, mentionLookup: state.mentionLookup,
emojiUnicodeReplacer, emojiUnicodeReplacer,
lookupInlineOnebox,
allowedHrefSchemes: siteSettings.allowed_href_schemes ? siteSettings.allowed_href_schemes.split('|') : null, allowedHrefSchemes: siteSettings.allowed_href_schemes ? siteSettings.allowed_href_schemes.split('|') : null,
markdownIt: true markdownIt: true,
previewing
}; };
// note, this will mutate options due to the way the API is designed // note, this will mutate options due to the way the API is designed

View File

@ -112,6 +112,7 @@ const DEFAULT_LIST = [
'a.mention', 'a.mention',
'a.mention-group', 'a.mention-group',
'a.onebox', 'a.onebox',
'a.inline-onebox-loading',
'a[data-bbcode]', 'a[data-bbcode]',
'a[name]', 'a[name]',
'a[rel=nofollow]', 'a[rel=nofollow]',

View File

@ -0,0 +1,10 @@
require_dependency 'inline_oneboxer'
class InlineOneboxController < ApplicationController
before_filter :ensure_logged_in
def show
oneboxes = InlineOneboxer.new(params[:urls]).process
render json: { "inline-oneboxes" => oneboxes }
end
end

View File

@ -123,17 +123,12 @@ SQL
internal = false internal = false
topic_id = nil topic_id = nil
post_number = nil post_number = nil
parsed_path = parsed.path || ""
if Discourse.store.has_been_uploaded?(url) if Discourse.store.has_been_uploaded?(url)
internal = Discourse.store.internal? internal = Discourse.store.internal?
elsif (parsed.host == Discourse.current_hostname && parsed_path.start_with?(Discourse.base_uri)) || !parsed.host elsif route = Discourse.route_for(parsed)
internal = true internal = true
parsed_path.slice!(Discourse.base_uri)
route = Rails.application.routes.recognize_path(parsed_path)
# We aren't interested in tracking internal links to users # We aren't interested in tracking internal links to users
next if route[:controller] == 'users' next if route[:controller] == 'users'

View File

@ -664,6 +664,7 @@ Discourse::Application.routes.draw do
end end
get "onebox" => "onebox#show" get "onebox" => "onebox#show"
get "inline-onebox" => "inline_onebox#show"
get "exception" => "list#latest" get "exception" => "list#latest"

View File

@ -214,6 +214,23 @@ module Discourse
base_url_no_prefix + base_uri base_url_no_prefix + base_uri
end end
def self.route_for(uri)
uri = URI(uri) rescue nil unless (uri.is_a?(URI))
return unless uri
path = uri.path || ""
if (uri.host == Discourse.current_hostname &&
path.start_with?(Discourse.base_uri)) ||
!uri.host
path.slice!(Discourse.base_uri)
return Rails.application.routes.recognize_path(path)
end
nil
end
READONLY_MODE_KEY_TTL ||= 60 READONLY_MODE_KEY_TTL ||= 60
READONLY_MODE_KEY ||= 'readonly_mode'.freeze READONLY_MODE_KEY ||= 'readonly_mode'.freeze
PG_READONLY_MODE_KEY ||= 'readonly_mode:postgres'.freeze PG_READONLY_MODE_KEY ||= 'readonly_mode:postgres'.freeze

47
lib/inline_oneboxer.rb Normal file
View File

@ -0,0 +1,47 @@
class InlineOneboxer
def initialize(urls)
@urls = urls
end
def process
@urls.map {|url| InlineOneboxer.lookup(url) }.compact
end
def self.clear_cache!
end
def self.cache_lookup(url)
Rails.cache.read(cache_key(url))
end
def self.lookup(url)
cached = cache_lookup(url)
return cached if cached.present?
if route = Discourse.route_for(url)
if route[:controller] == "topics" &&
route[:action] == "show" &&
topic = Topic.where(id: route[:topic_id].to_i).first
# Only public topics
if Guardian.new.can_see?(topic)
onebox = { url: url, title: topic.title }
Rails.cache.write(cache_key(url), onebox, expires_in: 1.day)
return onebox
end
end
end
nil
end
private
def self.cache_key(url)
"inline_onebox:#{url}"
end
end

View File

@ -164,6 +164,7 @@ module PrettyText
__optInput.mentionLookup = __mentionLookup; __optInput.mentionLookup = __mentionLookup;
__optInput.customEmoji = #{custom_emoji.to_json}; __optInput.customEmoji = #{custom_emoji.to_json};
__optInput.emojiUnicodeReplacer = __emojiUnicodeReplacer; __optInput.emojiUnicodeReplacer = __emojiUnicodeReplacer;
__optInput.lookupInlineOnebox = __lookupInlineOnebox;
JS JS
if opts[:topicId] if opts[:topicId]

View File

@ -1,3 +1,5 @@
require_dependency 'inline_oneboxer'
module PrettyText module PrettyText
module Helpers module Helpers
extend self extend self
@ -43,6 +45,10 @@ module PrettyText
end end
end end
def lookup_inline_onebox(url)
InlineOneboxer.lookup(url)
end
def get_topic_info(topic_id) def get_topic_info(topic_id)
return unless topic_id.is_a?(Integer) return unless topic_id.is_a?(Integer)
# TODO this only handles public topics, secured one do not get this # TODO this only handles public topics, secured one do not get this

View File

@ -49,6 +49,10 @@ function __getURL(url) {
return url; return url;
} }
function __lookupInlineOnebox(url) {
return __helpers.lookup_inline_onebox(url);
}
function __getTopicInfo(i) { function __getTopicInfo(i) {
return __helpers.get_topic_info(i); return __helpers.get_topic_info(i);
} }

View File

@ -0,0 +1,54 @@
require 'rails_helper'
require_dependency 'inline_oneboxer'
describe InlineOneboxer do
before do
InlineOneboxer.clear_cache!
end
it "should return nothing with empty input" do
expect(InlineOneboxer.new([]).process).to be_blank
end
it "can onebox a topic" do
topic = Fabricate(:topic)
results = InlineOneboxer.new([topic.url]).process
expect(results).to be_present
expect(results[0][:url]).to eq(topic.url)
expect(results[0][:title]).to eq(topic.title)
end
it "doesn't onebox private messages" do
topic = Fabricate(:private_message_topic)
results = InlineOneboxer.new([topic.url]).process
expect(results).to be_blank
end
context "caching" do
it "puts an entry in the cache" do
topic = Fabricate(:topic)
expect(InlineOneboxer.cache_lookup(topic.url)).to be_blank
result = InlineOneboxer.lookup(topic.url)
expect(result).to be_present
cached = InlineOneboxer.cache_lookup(topic.url)
expect(cached).to be_present
expect(cached[:url]).to eq(topic.url)
expect(cached[:title]).to eq(topic.title)
end
end
context ".lookup" do
it "can lookup one link at a time" do
topic = Fabricate(:topic)
onebox = InlineOneboxer.lookup(topic.url)
expect(onebox).to be_present
expect(onebox[:url]).to eq(topic.url)
expect(onebox[:title]).to eq(topic.title)
end
end
end

View File

@ -951,4 +951,19 @@ HTML
expect(cooked).to eq(html.strip) expect(cooked).to eq(html.strip)
end end
end end
describe "inline onebox" do
it "includes the topic title" do
topic = Fabricate(:topic)
raw = "Hello #{topic.url}"
cooked = <<~HTML
<p>Hello <a href="#{topic.url}">#{topic.title}</a></p>
HTML
expect(PrettyText.cook(raw)).to eq(cooked.strip)
end
end
end end

View File

@ -0,0 +1,37 @@
require 'rails_helper'
describe InlineOneboxController do
it "requires the user to be logged in" do
expect { xhr :get, :show, urls: [] }.to raise_error(Discourse::NotLoggedIn)
end
context "logged in" do
let!(:user) { log_in(:user) }
it "returns empty JSON for empty input" do
xhr :get, :show, urls: []
expect(response).to be_success
json = JSON.parse(response.body)
expect(json['inline-oneboxes']).to eq([])
end
context "topic link" do
let(:topic) { Fabricate(:topic) }
it "returns information for a valid link" do
xhr :get, :show, urls: [ topic.url ]
expect(response).to be_success
json = JSON.parse(response.body)
onebox = json['inline-oneboxes'][0]
expect(onebox).to be_present
expect(onebox['url']).to eq(topic.url)
expect(onebox['title']).to eq(topic.title)
end
end
end
end