diff --git a/bin/require.pip b/bin/require.pip index d691a00e5d..e9f2315b2d 100644 --- a/bin/require.pip +++ b/bin/require.pip @@ -3,3 +3,4 @@ GitPython==2.1.3 requests==2.18.2 sarge==0.1.4 pytest==3.2.1 +psycopg2==2.7.5 diff --git a/docs/METRICS.md b/docs/METRICS.md index b65666c95e..96a89e5764 100644 --- a/docs/METRICS.md +++ b/docs/METRICS.md @@ -290,11 +290,6 @@ These are events that an add-on user can encounter on a shot they own 8. [x] Confirm delete from shot index `web/delete/my-shots-popup-confirm` 9. [x] Cancel delete from shot index, `web/cancel-delete/my-shots-popup-confirm` 7. [x] Click My Shots `web/goto-myshots/navbar` -11. [x] Try to change expiration time `web/start-expiration-change/navbar` - 12. [x] Cancel changing expiration time `web/cancel-expiration-change/navbar` - 13. [x] Change expiration time `web/set-expiration/navbar` - 14. [x] Change expiration time to specific time `web/set-expiration-to-time/navbar` - 15. [x] Change expiration time to indefinite `web/set-expiration-to-indefinite/navbar` 16. [x] View expired shot `web/view-expired/owner` 17. [x] Recover expired shot `web/recover-expired` 19. [x] Visit original page `web/view-original/navbar-owner` @@ -316,6 +311,8 @@ These are events that an add-on user can encounter on a shot they own 33. [x] Visit an image directly, when the image isn't embedded directly in a Screenshots shot page, `web/visit/direct-view-owner` 34. [x] View an image directly, when the image is being shown as part of a Facebook/Twitter style preview (the og:image or twitter:image), `web/visit/direct-view-embedded-owner` 35. [x] Close new edit tools promotion dialog, `web/promo-closed` +1. [x] Add shot to favorites `web/set-favorite/navbar` +1. [x] Remove shot from favorites `web-unset-favorite/navbar` #### Shot Index (My Shots) diff --git a/locales/en-US/server.ftl b/locales/en-US/server.ftl index 30d7967ab8..3d5da4b2b0 100644 --- a/locales/en-US/server.ftl +++ b/locales/en-US/server.ftl @@ -289,6 +289,11 @@ shotIndexPageNextPage = # language/culture). shotIndexNoExpirationSymbol = ∞ .title = This shot does not expire +# This is the tooltip for a "heart" symbol in the lower right corner of the +# card for a shot on the My Shots page. It indicate that the shot was marked as +# a favorite by the owner. +shotIndexFavoriteIcon = + .title = This is a favorite shot and it does not expire ## Delete Confirmation Dialog diff --git a/server/src/pages/shot/model.js b/server/src/pages/shot/model.js index e896ffb2ed..81b7fe5a6f 100644 --- a/server/src/pages/shot/model.js +++ b/server/src/pages/shot/model.js @@ -15,6 +15,7 @@ exports.createModel = function(req) { } const title = req.getText("shotPageTitle", {originalTitle: req.shot.title}); const enableAnnotations = req.config.enableAnnotations; + const isFxaAuthenticated = req.accountId && req.accountId === req.shot.accountId; const serverPayload = { title, staticLink: req.staticLink, @@ -25,7 +26,8 @@ exports.createModel = function(req) { id: req.shot.id, productName: req.config.productName, isExtInstalled: !!req.deviceId, - isOwner: req.deviceId === req.shot.ownerId || (req.accountId && req.accountId === req.shot.accountId), + isOwner: req.deviceId === req.shot.ownerId || isFxaAuthenticated, + isFxaAuthenticated, gaId: req.config.gaId, deviceId: req.deviceId, authenticated: !!req.deviceId, @@ -54,7 +56,8 @@ exports.createModel = function(req) { id: req.shot.id, productName: req.config.productName, isExtInstalled: !!req.deviceId, - isOwner: req.deviceId === req.shot.ownerId || (req.accountId && req.accountId === req.shot.accountId), + isOwner: req.deviceId === req.shot.ownerId || isFxaAuthenticated, + isFxaAuthenticated, gaId: req.config.gaId, deviceId: req.deviceId, authenticated: !!req.deviceId, diff --git a/server/src/pages/shot/view.js b/server/src/pages/shot/view.js index 780ad42893..411808ccfc 100644 --- a/server/src/pages/shot/view.js +++ b/server/src/pages/shot/view.js @@ -176,7 +176,6 @@ class Body extends React.Component { this.state = { hidden: false, closeBanner: false, - isChangingExpire: false, imageEditing: false }; } @@ -342,19 +341,21 @@ class Body extends React.Component { const linkTextShort = shot.urlDisplay; const timeDiff = ; - let expiresDiff = null; - if (this.props.isOwner) { - expiresDiff = - - ; - } + let favoriteShotButton = null; let trashOrFlagButton; let editButton; const highlight = this.state.highlightEditButton ?
: null; + + if (this.props.isFxaAuthenticated) { + const activeFavClass = this.props.expireTime ? "" : "is-fav"; + favoriteShotButton =
; + } + if (this.props.isOwner) { trashOrFlagButton =
- { linkTextShort && !this.state.isChangingExpire ? { linkTextShort } : null } - { this.state.isChangingExpire ? null : { timeDiff } } - { expiresDiff } + { linkTextShort ? { linkTextShort } : null } + { timeDiff }
+ { favoriteShotButton } { trashOrFlagButton } { this.props.enableAnnotations ? editButton : null } { highlight } @@ -508,20 +509,6 @@ class Body extends React.Component { sendEvent("click-install-firefox-shot", {useBeacon: true}); } - onSaveExpire(value) { - sendEvent("set-expiration", "navbar"); - if (value === 0 || value === "0") { - sendEvent("set-expiration-to-indefinite", "navbar"); - } else { - sendEvent("set-expiration-to-time", "navbar"); - } - this.props.controller.changeShotExpiration(this.props.shot, value); - } - - onChangingExpire(value) { - this.setState({isChangingExpire: value}); - } - onRestore() { sendEvent("recover-expired"); this.props.controller.changeShotExpiration(this.props.shot, this.props.defaultExpiration); @@ -545,6 +532,18 @@ class Body extends React.Component { // Note: we allow the default action to continue } + onClickFavorite() { + if (this.props.expireTime) { + sendEvent("set-favorite", "navbar"); + const INDEFINITE = 0; + this.props.controller.changeShotExpiration(this.props.shot, INDEFINITE); + } else { + sendEvent("unset-favorite", "navbar"); + const TWO_WEEKS_IN_MS = 1209600000; + this.props.controller.changeShotExpiration(this.props.shot, Date.now() + TWO_WEEKS_IN_MS); + } + } + onClickDownload() { sendEvent("download", "navbar"); } @@ -570,6 +569,7 @@ Body.propTypes = { isExtInstalled: PropTypes.bool, isMobile: PropTypes.bool, isOwner: PropTypes.bool, + isFxaAuthenticated: PropTypes.bool, loginFailed: PropTypes.bool, pngToJpegCutoff: PropTypes.number, retentionTime: PropTypes.number, @@ -580,96 +580,6 @@ Body.propTypes = { userLocales: PropTypes.array }; -class ExpireWidget extends React.Component { - - constructor(props) { - super(props); - this.state = {isChangingExpire: false}; - } - - render() { - if (this.state.isChangingExpire) { - return this.renderChanging(); - } - return this.renderNormal(); - } - - renderChanging() { - const minute = 60 * 1000; - const hour = minute * 60; - const day = hour * 24; - return ( - - How long should this shot be retained? - - save - cancel - - ); - } - - renderNormal() { - let button; - if (this.props.expireTime === null) { - button = does not expire; - } else { - const expired = this.props.expireTime < Date.now(); - const timediff = ; - if (expired) { - button = expired {timediff}; - } else { - button = expires {timediff}; - } - } - return ( - - ); - } - - clickChangeExpire() { - sendEvent("start-expiration-change", "navbar"); - this.setState({isChangingExpire: true}); - this.props.onChanging(true); - } - - clickCancelExpire() { - sendEvent("cancel-expiration-change", "navbar"); - this.setState({isChangingExpire: false}); - this.props.onChanging(false); - } - - clickSaveExpire() { - // FIXME: save the value that it was changed to? Yes! Not sure where to put it. - let value = this.expireTime.value; - if (value === "cancel") { - this.clickCancelExpire(); - return; - } - value = parseInt(value, 10); - // Note: sendEvent done in onSaveExpire - this.props.onSaveExpire(value); - this.props.onChanging(false); - this.setState({isChangingExpire: false}); - } -} - -ExpireWidget.propTypes = { - expireTime: PropTypes.number, - onChanging: PropTypes.func, - onSaveExpire: PropTypes.func, - simple: PropTypes.bool -}; class EditableTitle extends React.Component { diff --git a/server/src/pages/shotindex/model.js b/server/src/pages/shotindex/model.js index 80117a8e61..2062cbb793 100644 --- a/server/src/pages/shotindex/model.js +++ b/server/src/pages/shotindex/model.js @@ -8,6 +8,7 @@ exports.createModel = function(req) { } const serverModel = { title, + hasFxa: !!req.accountId, hasDeviceId: req.deviceId !== undefined, defaultSearch: query || null }; diff --git a/server/src/pages/shotindex/view.js b/server/src/pages/shotindex/view.js index 0271369b36..c041373324 100644 --- a/server/src/pages/shotindex/view.js +++ b/server/src/pages/shotindex/view.js @@ -63,7 +63,7 @@ class Body extends React.Component { const children = []; if (this.props.shots && this.props.shots.length) { for (const shot of this.props.shots) { - children.push(); + children.push(); } } @@ -281,6 +281,7 @@ Body.propTypes = { downloadUrls: PropTypes.object, enableUserSettings: PropTypes.bool, hasDeviceId: PropTypes.bool, + hasFxa: PropTypes.bool, isExtInstalled: PropTypes.bool, isOwner: PropTypes.bool, pageNumber: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), @@ -329,7 +330,11 @@ class Card extends React.Component { let neverExpireIndicator = null; if (!shot.expireTime) { - neverExpireIndicator =
; + if (this.props.hasFxa) { + neverExpireIndicator =
; + } else { + neverExpireIndicator =
; + } } const deleteConfirmationClass = this.state.deletePanelOpen ? "panel-open" : ""; @@ -462,6 +467,7 @@ class Card extends React.Component { Card.propTypes = { abTests: PropTypes.object, downloadUrl: PropTypes.string, + hasFxa: PropTypes.bool, isExtInstalled: PropTypes.bool, isOwner: PropTypes.bool, shot: PropTypes.object, diff --git a/server/src/server.js b/server/src/server.js index d177e07052..67067315a6 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -861,7 +861,7 @@ app.post("/api/save-edit", function(req, res) { }); app.post("/api/set-expiration", function(req, res) { - if (!req.deviceId) { + if (!req.deviceId || !req.accountId) { sendRavenMessage(req, "Attempt to set expiration without login"); simpleResponse(res, "Not logged in", 401); return; diff --git a/static/css/frame.scss b/static/css/frame.scss index f73ee6f614..f616651f61 100644 --- a/static/css/frame.scss +++ b/static/css/frame.scss @@ -128,6 +128,26 @@ } } +.fav-wrapper { + &:active, + &:hover { + background-color: $light-hover; + } + border-radius: $border-radius; + margin-right: $grid-unit * 0.5; +} +.favorite { + background: url("../img/icon-heart.svg"); + background-size: 20px 20px; + background-position: center; + background-repeat: no-repeat; + filter: brightness(2.4); + + &.is-fav { + filter: brightness(1); + } +} + .delete-confirmation-dialog { &.right-align { top: 48px; @@ -567,7 +587,7 @@ body { height: 40px; border-radius: 3px; position: absolute; - background-color: #ededf0; + background-color: $light-hover; } #color-picker { diff --git a/static/css/shot-index.scss b/static/css/shot-index.scss index 0b6432e8d1..8c84b1ff95 100644 --- a/static/css/shot-index.scss +++ b/static/css/shot-index.scss @@ -156,6 +156,7 @@ h1 { .alt-actions-container { opacity: 1; } + .favorite-shot, .never-expires { display: none; } @@ -244,6 +245,7 @@ h1 { margin: 12px; } + .favorite-shot, .never-expires { bottom: 0; color: #9e9e9e; @@ -251,6 +253,15 @@ h1 { position: absolute; right: 8px; } + + .favorite-shot { + background-image: url("../img/icon-heart.svg"); + background-position: center; + background-repeat: no-repeat; + background-size: 16px 16px; + width: 16px; + height: 26px; + } } .preferences { diff --git a/static/img/icon-heart.svg b/static/img/icon-heart.svg new file mode 100644 index 0000000000..a40eae33ca --- /dev/null +++ b/static/img/icon-heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/server/clientlib.py b/test/server/clientlib.py index b02888b0ea..2541893d0c 100644 --- a/test/server/clientlib.py +++ b/test/server/clientlib.py @@ -7,6 +7,7 @@ import uuid import random import time +from pglib import attach_device example_images = {} execfile(os.path.normpath(os.path.join(__file__, "../../../bin/load_test_exercise_images.py")), example_images) @@ -20,6 +21,7 @@ def __init__(self, backend="http://localhost:10080"): self.deviceInfo = make_device_info() self.deviceId = make_uuid() self.secret = make_uuid() + self.accountId = make_random_id() self.session = requests.Session() self.session.headers.update({'Accept-Language': 'en-US'}) @@ -31,6 +33,8 @@ def login(self): resp = self.session.post( urljoin(self.backend, "/api/register"), data=dict(deviceId=self.deviceId, secret=self.secret, deviceInfo=json.dumps(self.deviceInfo))) + account_info = attach_device(self.deviceId, self.accountId) + self.session.cookies.update(account_info) resp.raise_for_status() return resp diff --git a/test/server/pglib.py b/test/server/pglib.py new file mode 100644 index 0000000000..90475eedcf --- /dev/null +++ b/test/server/pglib.py @@ -0,0 +1,42 @@ +# This is for faking an FxA auth by directly updating the database. FxA is +# required for setting a shot's expiration, which is in multiple tests. + +import os +import subprocess +import shlex +import psycopg2 +import uuid +import hmac +import hashlib +from base64 import urlsafe_b64encode + +dir_path = os.path.dirname(os.path.realpath(__file__)) +pg_vars_path = os.path.join(dir_path, "..", "..", "bin", "pg_vars") +pg_vars_out = subprocess.check_output(pg_vars_path) +parts = [line.partition('=') for line in shlex.split(pg_vars_out)] +pg_vars = {name: val for name, _, val in parts} + + +# Attaches a device_id to an account_id. Returns a dict that can be stuffed +# into a cookie jar. +def attach_device(device_id, account_id): + dbname = pg_vars["PGDATABASE"] or pg_vars["PGUSER"] + conn = psycopg2.connect(dbname=dbname, user=pg_vars["PGUSER"], host=pg_vars["PGHOST"], port=pg_vars["PGPORT"]) + conn.autocommit = True + cur = conn.cursor() + token = str(uuid.uuid1()) + cur.execute("INSERT INTO accounts (id, token) VALUES (%s, %s)", (account_id, token)) + cur.execute("UPDATE devices SET accountid = %s WHERE id = %s", (account_id, device_id)) + cur.execute("SELECT key FROM signing_keys ORDER BY created DESC LIMIT 1") + key_row = cur.fetchone() + account_id_hmac = __get_hmac("accountid=%s" % account_id, key_row[0]) + cur.close() + conn.close() + return {"accountid": account_id, "accountid.sig": account_id_hmac} + + +def __get_hmac(val, key): + h = hmac.new(key, None, hashlib.sha1) + h.update(val) + b64 = urlsafe_b64encode(h.digest()) + return b64.replace('=', '')