Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Gemfile.saas.lock
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ GEM
tzinfo
faker (3.5.2)
i18n (>= 1.8.11, < 2)
faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.1)
net-http (>= 0.5.0)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
Expand All @@ -251,6 +257,7 @@ GEM
globalid (1.3.0)
activesupport (>= 6.1)
hashdiff (1.2.1)
hashie (5.0.0)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
Expand Down Expand Up @@ -323,6 +330,10 @@ GEM
mocha (2.8.2)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
multi_xml (0.7.2)
bigdecimal (~> 3.1)
net-http (0.6.0)
uri
net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4)
net-imap (0.5.12)
Expand Down Expand Up @@ -354,6 +365,14 @@ GEM
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl)
racc (~> 1.4)
oauth2 (2.0.17)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.3)
version_gem (~> 1.1, >= 1.1.9)
openssl (3.3.2)
ostruct (0.6.3)
parallel (1.27.0)
Expand Down Expand Up @@ -493,6 +512,9 @@ GEM
sentry-ruby (6.2.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
snaky_hash (2.0.3)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
sniffer (0.5.0)
anyway_config (>= 1.0)
dry-initializer (~> 3)
Expand Down Expand Up @@ -548,6 +570,7 @@ GEM
uri (1.1.1)
vcr (6.3.1)
base64
version_gem (1.1.9)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
Expand Down Expand Up @@ -631,6 +654,7 @@ DEPENDENCIES
mittens
mocha
net-http-persistent
oauth2 (~> 2.0)
platform_agent
prometheus-client-mmap (~> 1.3)
propshaft
Expand Down
9 changes: 9 additions & 0 deletions app/assets/stylesheets/lexxy.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@
}
}

lexxy-editor[data-controller~="unfurl-link"] {
display: flex;
flex-direction: column;

[data-unfurl-link-target~="linkAccountsPrompt"] {
order: 999;
}
}

.lexxy-dialog-actions {
display: flex;
font-size: var(--text-x-small);
Expand Down
15 changes: 15 additions & 0 deletions app/assets/stylesheets/rich-text-content.css
Original file line number Diff line number Diff line change
Expand Up @@ -393,4 +393,19 @@
line-height: inherit;
margin-block: 1em;
}

.unfurl-notice {
background-color: var(--color-selected);
border-radius: 0.5em;
font-size: var(--text-small);
padding: 0.5em 0.5em 0.5em 1em;

p {
margin: 0;
}

.btn {
white-space: nowrap;
}
}
}
22 changes: 22 additions & 0 deletions app/controllers/unfurl_links_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
class UnfurlLinksController < ApplicationController
rate_limit to: 50, within: 1.hour, by: -> { Current.user.id }

def create
link = Link.unfurl(url_param)

if link.unfurler.requires_setup?
render \
json: { error: :unfurler_requires_setup, config: link.unfurler.setup_config },
status: :unprocessable_entity
elsif link.metadata
render json: link.metadata
else
head :no_content
end
end

private
def url_param
params.require(:url)
end
end
32 changes: 32 additions & 0 deletions app/helpers/rich_text_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,36 @@ def code_language_picker
def general_prompts(board)
safe_join([ mentions_prompt(board), cards_prompt, code_language_picker ])
end

def lexxy_rich_textarea_tag(name, value = nil, options = {}, &block)
options = options.symbolize_keys
unfurl_links = options.key?(:unfurl_links) ? options.delete(:unfurl_links) : true

if unfurl_links
data = options[:data] ||= {}

data[:controller] = token_list(data[:controller], "unfurl-link")
data[:unfurl_link_url_value] ||= unfurl_link_path
data[:unfurl_link_set_up_basecamp_integration_url_value] ||= new_basecamp_integration_url
data[:action] = token_list(data[:action], "lexxy:insert-link->unfurl-link#unfurl")
end

super(name, value, options) do
concat link_unfurling_prompt if unfurl_links
concat capture(&block) if block_given?
end
end

private
def link_unfurling_prompt
content_tag(:div, hidden: true, class: "unfurl-notice flex gap justify-space-between align-center", data: { unfurl_link_target: "linkAccountsPrompt" }) do
concat content_tag(:p, "Connect your account to get previews of Basecamp links?")
concat(
content_tag(:div, class: "flex gap-half") do
concat button_tag("Yes, connect…", class: "btn btn--link", data: { action: "unfurl-link#setUpBasecampIntegration" })
concat button_tag("Not now", class: "btn fill-transparent", data: { action: "unfurl-link#closePrompt", unfurl_link_intent_param: "dismiss" })
end
)
end
end
end
7 changes: 7 additions & 0 deletions app/javascript/controllers/popup_window_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
close() {
window.close()
}
}
109 changes: 109 additions & 0 deletions app/javascript/controllers/unfurl_link_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Controller } from "@hotwired/stimulus";
import { post } from "@rails/request.js"
import Cookie from "models/cookie"

export default class extends Controller {
static targets = [ "linkAccountsPrompt" ]
static values = {
url: String,
setUpBasecampIntegrationUrl: String
}

static MAX_DISMISSAL_COUNT = 3
static DISMISSAL_COUNTER_KEY = "basecamp_integration_dimissal_count"

#accountLinkingDismissed

unfurl(event) {
this.#unfurlLink(event.detail.url, event.detail)
}

setUpBasecampIntegration() {
this.#openPopup(this.setUpBasecampIntegrationUrlValue, { width: 400, height: 600 })
}

closePrompt(event) {
this.linkAccountsPromptTarget.hidden = true

if (event.params.intent === "dismiss") {
this.#incrementDismissalCounter()
}
}

async #unfurlLink(url, callbacks) {
const { response } = await post(
this.urlValue,
{
body: JSON.stringify({ url }),
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
}
)

let metadata = null

if (response.status !== 204) {
metadata = await response.json()
}

if (metadata?.error) {
this.#handleError(metadata)
} else if (metadata) {
this.#insertUnfurledLink(metadata, callbacks)
}
}

#insertUnfurledLink(metadata, callbacks) {
callbacks.replaceLinkWith(this.#renderUnfurledLinkHTML(metadata))
}

#renderUnfurledLinkHTML(metadata) {
return `<a href="${metadata.canonical_url}">${metadata.title}</a>`
}

#handleError({ error, ...data }) {
switch (error) {
case "unfurler_requires_setup":
if (this.#shouldShowUnfurlerSetupPrompt()) {
this.#promptUnfurlerSetUp(data.config)
}
break;
default:
throw new Error(`Unknown API error: ${error}`)
break;
}
}

#promptUnfurlerSetUp() {
this.linkAccountsPromptTarget.hidden = false
}

#openPopup(url, options, onClose) {
const { width, height } = options
const left = (window.screen.width - width) / 2
const top = (window.screen.height - height) / 2

window.open(
url,
"_blank",
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,toolbar=no,menubar=no,location=no,status=no`
)
}

#incrementDismissalCounter() {
this.#cookie.increment(this.constructor.DISMISSAL_COUNTER_KEY)
this.#accountLinkingDismissed = true
}

#shouldShowUnfurlerSetupPrompt() {
const dismissalCount = this.#cookie.get(this.constructor.DISMISSAL_COUNTER_KEY) || 0
return !this.#accountLinkingDismissed && (dismissalCount < this.constructor.MAX_DISMISSAL_COUNT)
}

get #cookie() {
const name = `link-unfurler-setup-prompt`
return Cookie.find(name) || new Cookie(name)
}
}
58 changes: 58 additions & 0 deletions app/javascript/models/cookie.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export default class Cookie {
static DEFAULT_EXPIRATION = 20 * 365 * 24 * 60 * 60 * 1000

static find(name) {
const value = document.cookie
.split("; ")
.find(row => row.startsWith(`${name}=`))
?.split("=")[1]

if (!value) return null

try {
const data = JSON.parse(decodeURIComponent(value))
return new Cookie(name, data)
} catch {
return new Cookie(name, { value: decodeURIComponent(value) })
}
}

constructor(name, data = {}, options = {}) {
this.name = name
this.data = data
this.options = options
}

get(key) {
return this.data[key]
}

set(key, value) {
this.data[key] = value
this.save()
}

increment(key, amount = 1) {
const currentValue = this.data[key] || 0

try {
this.set(key, currentValue + amount)
} catch {
this.set(key, amount)
}
}

save() {
const value = encodeURIComponent(JSON.stringify(this.data))
const defaultExpires = new Date(Date.now() + this.constructor.DEFAULT_EXPIRATION)
const expires = `; expires=${(this.options.expires || defaultExpires).toUTCString()}`
const path = `; path=${this.options.path || "/"}`
const sameSite = this.options.sameSite ? `; SameSite=${this.options.sameSite}` : "; SameSite=Lax"

document.cookie = `${this.name}=${value}${expires}${path}${sameSite}`
}

delete() {
document.cookie = `${this.name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`
}
}
5 changes: 5 additions & 0 deletions app/jobs/integration/basecamp/set_up_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Integration::Basecamp::SetUpJob < ApplicationJob
def perform(code:, state:)
Integration::Basecamp.set_up(code: code, state: state)
end
end
5 changes: 5 additions & 0 deletions app/models/integration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Integration < ApplicationRecord
belongs_to :owner, class_name: "User"

store :data, coder: JSON
end
32 changes: 32 additions & 0 deletions app/models/link.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
class Link
UNFURLERS = [
FizzyUnfurler,
]

attr_reader :uri, :metadata, :user

def self.unfurl(url, **options)
new(url).unfurl(**options)
end

def initialize(url, user: Current.user)
@uri = URI.parse(url)
@metadata = nil
@user = user
end

def unfurl
if unfurler&.setup?
@metadata = unfurler.unfurl
end

self
end

def unfurler
@unfurler ||= begin
unfurler = UNFURLERS.find { |unfurler| unfurler.unfurls?(uri) }
unfurler&.new(uri, user: user)
end
end
end
Loading
Loading