Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
coopwire-hypothesis
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
孙灵跃 Leon Sun
coopwire-hypothesis
Commits
5b672230
Unverified
Commit
5b672230
authored
Aug 02, 2019
by
Lyza Gardner
Committed by
GitHub
Aug 02, 2019
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #1283 from hypothesis/created-edited
Add edited timestamp to annotation headers
parents
4248f4bf
9f41b616
Changes
6
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
199 additions
and
128 deletions
+199
-128
annotation-header.js
src/sidebar/components/annotation-header.js
+22
-6
annotation-header-test.js
src/sidebar/components/test/annotation-header-test.js
+32
-5
annotation-fixtures.js
src/sidebar/test/annotation-fixtures.js
+1
-0
time-test.js
src/sidebar/util/test/time-test.js
+16
-16
time.js
src/sidebar/util/time.js
+110
-100
annotation-header.scss
src/styles/sidebar/components/annotation-header.scss
+18
-1
No files found.
src/sidebar/components/annotation-header.js
View file @
5b672230
...
...
@@ -24,6 +24,9 @@ function AnnotationHeader({
})
{
const
annotationLink
=
annotation
.
links
?
annotation
.
links
.
html
:
''
;
const
replyPluralized
=
!
replyCount
||
replyCount
>
1
?
'replies'
:
'reply'
;
// NB: `created` and `updated` are strings, not `Date`s
const
hasBeenEdited
=
annotation
.
updated
&&
annotation
.
created
!==
annotation
.
updated
;
return
(
<
header
className
=
"annotation-header"
>
...
...
@@ -34,13 +37,26 @@ function AnnotationHeader({
{
replyCount
}
{
replyPluralized
}
<
/a
>
<
/div
>
{
!
isEditing
&&
annotation
.
upd
ated
&&
(
{
!
isEditing
&&
annotation
.
cre
ated
&&
(
<
div
className
=
"annotation-header__timestamp"
>
<
Timestamp
className
=
"annotation-header__timestamp-link"
href
=
{
annotationLink
}
timestamp
=
{
annotation
.
updated
}
/
>
{
hasBeenEdited
&&
(
<
span
className
=
"annotation-header__timestamp-edited"
>
(
edited
{
' '
}
<
Timestamp
className
=
"annotation-header__timestamp-edited-link"
href
=
{
annotationLink
}
timestamp
=
{
annotation
.
updated
}
/
>
){
' '
}
<
/span
>
)}
<
span
className
=
"annotation-header__timestamp-created"
>
<
Timestamp
className
=
"annotation-header__timestamp-created-link"
href
=
{
annotationLink
}
timestamp
=
{
annotation
.
created
}
/
>
<
/span
>
<
/div
>
)}
<
/div
>
...
...
src/sidebar/components/test/annotation-header-test.js
View file @
5b672230
...
...
@@ -62,19 +62,46 @@ describe('AnnotationHeader', () => {
);
});
describe
(
'timestamp'
,
()
=>
{
it
(
'should render
a timestamp if annotation has an `upd
ated` value'
,
()
=>
{
describe
(
'timestamp
s
'
,
()
=>
{
it
(
'should render
timestamp container element if annotation has a `cre
ated` value'
,
()
=>
{
const
wrapper
=
createAnnotationHeader
();
const
timestamp
=
wrapper
.
find
(
Timestamp
);
const
timestamp
=
wrapper
.
find
(
'.annotation-header__timestamp'
);
assert
.
isTrue
(
timestamp
.
exists
());
});
it
(
'should not render
a timestamp if annotation does not have an `upd
ated` value'
,
()
=>
{
it
(
'should not render
timestamp container if annotation does not have a `cre
ated` value'
,
()
=>
{
const
wrapper
=
createAnnotationHeader
({
annotation
:
fixtures
.
newAnnotation
(),
});
const
timestamp
=
wrapper
.
find
(
Timestamp
);
const
timestamp
=
wrapper
.
find
(
'.annotation-header__timestamp'
);
assert
.
isFalse
(
timestamp
.
exists
());
});
it
(
'should render edited timestamp if annotation has been edited'
,
()
=>
{
const
annotation
=
fixtures
.
defaultAnnotation
();
annotation
.
updated
=
'2018-05-10T20:18:56.613388+00:00'
;
const
wrapper
=
createAnnotationHeader
({
annotation
:
annotation
,
});
const
timestamp
=
wrapper
.
find
(
Timestamp
)
.
filter
(
'.annotation-header__timestamp-edited-link'
);
assert
.
isTrue
(
timestamp
.
exists
());
});
it
(
'should not render edited timestamp if annotation has not been edited'
,
()
=>
{
// Default annotation's created value is same as updated; as if the annotation
// has not been edited before
const
wrapper
=
createAnnotationHeader
({
annotation
:
fixtures
.
newAnnotation
(),
});
const
timestamp
=
wrapper
.
find
(
Timestamp
)
.
filter
(
'.annotation-header__timestamp-edited-link'
);
assert
.
isFalse
(
timestamp
.
exists
());
});
...
...
src/sidebar/test/annotation-fixtures.js
View file @
5b672230
...
...
@@ -6,6 +6,7 @@
function
defaultAnnotation
()
{
return
{
id
:
'deadbeef'
,
created
:
'2015-05-10T20:18:56.613388+00:00'
,
document
:
{
title
:
'A special document'
,
},
...
...
src/sidebar/util/test/time-test.js
View file @
5b672230
...
...
@@ -2,10 +2,10 @@
const
time
=
require
(
'../time'
);
const
minute
=
60
;
const
second
=
1000
;
const
minute
=
second
*
60
;
const
hour
=
minute
*
60
;
const
day
=
hour
*
24
;
const
msPerSecond
=
1000
;
describe
(
'sidebar.util.time'
,
function
()
{
let
sandbox
;
...
...
@@ -41,7 +41,7 @@ describe('sidebar.util.time', function() {
// - A user in the UK who views the annotation will see “Jan 1”
// on the annotation card (correct)
// - A user in San Francisco who views the annotation will see
// “Dec 31
st
2018" on the annotation card (also correct from
// “Dec 31
,
2018" on the annotation card (also correct from
// their point of view).
const
date
=
new
Date
(
isoString
);
date
.
getFullYear
=
sinon
.
stub
().
returns
(
date
.
getUTCFullYear
());
...
...
@@ -58,11 +58,11 @@ describe('sidebar.util.time', function() {
[
{
now
:
'1970-01-01T00:00:10.000Z'
,
text
:
'Just now'
},
{
now
:
'1970-01-01T00:00:29.000Z'
,
text
:
'Just now'
},
{
now
:
'1970-01-01T00:00:49.000Z'
,
text
:
'49 secs'
},
{
now
:
'1970-01-01T00:01:05.000Z'
,
text
:
'1 min'
},
{
now
:
'1970-01-01T00:03:05.000Z'
,
text
:
'3 mins'
},
{
now
:
'1970-01-01T01:00:00.000Z'
,
text
:
'1 hr'
},
{
now
:
'1970-01-01T04:00:00.000Z'
,
text
:
'4 hrs'
},
{
now
:
'1970-01-01T00:00:49.000Z'
,
text
:
'49 secs
ago
'
},
{
now
:
'1970-01-01T00:01:05.000Z'
,
text
:
'1 min
ago
'
},
{
now
:
'1970-01-01T00:03:05.000Z'
,
text
:
'3 mins
ago
'
},
{
now
:
'1970-01-01T01:00:00.000Z'
,
text
:
'1 hr
ago
'
},
{
now
:
'1970-01-01T04:00:00.000Z'
,
text
:
'4 hrs
ago
'
},
].
forEach
(
test
=>
{
it
(
'creates correct fuzzy string for fixture '
+
test
.
now
,
()
=>
{
const
timeStamp
=
fakeDate
(
'1970-01-01T00:00:00.000Z'
);
...
...
@@ -158,15 +158,15 @@ describe('sidebar.util.time', function() {
const
date
=
new
Date
().
toISOString
();
const
callback
=
sandbox
.
stub
();
time
.
decayingInterval
(
date
,
callback
);
sandbox
.
clock
.
tick
(
6
*
msPerS
econd
);
sandbox
.
clock
.
tick
(
6
*
s
econd
);
assert
.
calledWith
(
callback
,
date
);
sandbox
.
clock
.
tick
(
6
*
msPerS
econd
);
sandbox
.
clock
.
tick
(
6
*
s
econd
);
assert
.
calledTwice
(
callback
);
});
it
(
'uses a longer delay for older timestamps'
,
function
()
{
const
date
=
new
Date
().
toISOString
();
const
ONE_MINUTE
=
minute
*
msPerSecond
;
const
ONE_MINUTE
=
minute
;
sandbox
.
clock
.
tick
(
10
*
ONE_MINUTE
);
const
callback
=
sandbox
.
stub
();
time
.
decayingInterval
(
date
,
callback
);
...
...
@@ -183,13 +183,13 @@ describe('sidebar.util.time', function() {
const
callback
=
sandbox
.
stub
();
const
cancel
=
time
.
decayingInterval
(
date
,
callback
);
cancel
();
sandbox
.
clock
.
tick
(
minute
*
msPerSecond
);
sandbox
.
clock
.
tick
(
minute
);
assert
.
notCalled
(
callback
);
});
it
(
'does not set a timeout for dates > 24hrs ago'
,
function
()
{
const
date
=
new
Date
().
toISOString
();
const
ONE_DAY
=
day
*
msPerSecond
;
const
ONE_DAY
=
day
;
sandbox
.
clock
.
tick
(
10
*
ONE_DAY
);
const
callback
=
sandbox
.
stub
();
...
...
@@ -208,9 +208,9 @@ describe('sidebar.util.time', function() {
});
[
{
now
:
'1970-01-01T00:00:10.000Z'
,
expectedUpdateTime
:
5
},
// we have a minimum of 5 secs
{
now
:
'1970-01-01T00:00:20.000Z'
,
expectedUpdateTime
:
5
},
{
now
:
'1970-01-01T00:00:49.000Z'
,
expectedUpdateTime
:
5
},
{
now
:
'1970-01-01T00:00:10.000Z'
,
expectedUpdateTime
:
5
*
second
},
// we have a minimum of 5 secs
{
now
:
'1970-01-01T00:00:20.000Z'
,
expectedUpdateTime
:
5
*
second
},
{
now
:
'1970-01-01T00:00:49.000Z'
,
expectedUpdateTime
:
5
*
second
},
{
now
:
'1970-01-01T00:01:05.000Z'
,
expectedUpdateTime
:
minute
},
{
now
:
'1970-01-01T00:03:05.000Z'
,
expectedUpdateTime
:
minute
},
{
now
:
'1970-01-01T04:00:00.000Z'
,
expectedUpdateTime
:
hour
},
...
...
src/sidebar/util/time.js
View file @
5b672230
'use strict'
;
const
minute
=
60
;
const
hour
=
minute
*
60
;
function
lessThanThirtySecondsAgo
(
date
,
now
)
{
return
now
-
date
<
30
*
1000
;
}
function
lessThanOneMinuteAgo
(
date
,
now
)
{
return
now
-
date
<
60
*
1000
;
}
function
lessThanOneHourAgo
(
date
,
now
)
{
return
now
-
date
<
60
*
60
*
1000
;
}
function
lessThanOneDayAgo
(
date
,
now
)
{
return
now
-
date
<
24
*
60
*
60
*
1000
;
}
function
thisYear
(
date
,
now
)
{
return
date
.
getFullYear
()
===
now
.
getFullYear
();
}
function
delta
(
date
,
now
)
{
return
Math
.
round
((
now
-
date
)
/
1000
);
}
function
nSec
(
date
,
now
)
{
return
'{} secs'
.
replace
(
'{}'
,
Math
.
floor
(
delta
(
date
,
now
)));
}
function
nMin
(
date
,
now
)
{
const
n
=
Math
.
floor
(
delta
(
date
,
now
)
/
minute
);
let
template
=
'{} min'
;
if
(
n
>
1
)
{
template
=
template
+
's'
;
}
return
template
.
replace
(
'{}'
,
n
);
}
function
nHr
(
date
,
now
)
{
const
n
=
Math
.
floor
(
delta
(
date
,
now
)
/
hour
);
let
template
=
'{} hr'
;
if
(
n
>
1
)
{
template
=
template
+
's'
;
}
/**
* Utility functions for generating formatted "fuzzy" date strings and
* computing decaying intervals for updating those dates in a UI.
*/
return
template
.
replace
(
'{}'
,
n
);
}
const
SECOND
=
1000
;
const
MINUTE
=
60
*
SECOND
;
const
HOUR
=
60
*
MINUTE
;
// Cached DateTimeFormat instances,
// because instantiating a DateTimeFormat is expensive.
...
...
@@ -63,18 +19,32 @@ let formatters = {};
function
clearFormatters
()
{
formatters
=
{};
}
/**
*
Efficiently return `date` formatted with `options`.
*
Calculate time delta in milliseconds between two `Date` objects
*
* This is a wrapper for Intl.DateTimeFormat.format() that caches
* DateTimeFormat instances because they're expensive to create.
* Calling Date.toLocaleDateString() lots of times is also expensive in some
* @param {Date} date
* @param {Date} now
*/
function
delta
(
date
,
now
)
{
return
now
-
date
;
}
/**
* Efficiently return date string formatted with `options`.
*
* This is a wrapper for `Intl.DateTimeFormat.format()` that caches
* `DateTimeFormat` instances because they're expensive to create.
* Calling `Date.toLocaleDateString()` lots of times is also expensive in some
* browsers as it appears to create a new formatter for each call.
*
* @param {Date} date
* @param {Object} options - Options for `Intl.DateTimeFormat.format()`
* @param {Object} Intl - JS internationalization API implementation; this
* param is present for dependency injection during test.
* @returns {string}
*
*/
function
format
(
date
,
options
,
Intl
)
{
function
format
Intl
(
date
,
options
,
Intl
)
{
// If the tests have passed in a mock Intl then use it, otherwise use the
// real one.
if
(
typeof
Intl
===
'undefined'
)
{
...
...
@@ -96,12 +66,36 @@ function format(date, options, Intl) {
}
}
/**
* Date templating functions.
*
* @param {Date} date
* @param {Date} now
* @return {String} formatted date
*/
function
nSec
(
date
,
now
)
{
const
n
=
Math
.
floor
(
delta
(
date
,
now
)
/
SECOND
);
return
`
${
n
}
secs ago`
;
}
function
nMin
(
date
,
now
)
{
const
n
=
Math
.
floor
(
delta
(
date
,
now
)
/
MINUTE
);
const
plural
=
n
>
1
?
's'
:
''
;
return
`
${
n
}
min
${
plural
}
ago`
;
}
function
nHr
(
date
,
now
)
{
const
n
=
Math
.
floor
(
delta
(
date
,
now
)
/
HOUR
);
const
plural
=
n
>
1
?
's'
:
''
;
return
`
${
n
}
hr
${
plural
}
ago`
;
}
function
dayAndMonth
(
date
,
now
,
Intl
)
{
return
format
(
date
,
{
month
:
'short'
,
day
:
'numeric'
},
Intl
);
return
format
Intl
(
date
,
{
month
:
'short'
,
day
:
'numeric'
},
Intl
);
}
function
dayAndMonthAndYear
(
date
,
now
,
Intl
)
{
return
format
(
return
format
Intl
(
date
,
{
day
:
'numeric'
,
month
:
'short'
,
year
:
'numeric'
},
Intl
...
...
@@ -110,37 +104,39 @@ function dayAndMonthAndYear(date, now, Intl) {
const
BREAKPOINTS
=
[
{
test
:
lessThanThirtySecondsAgo
,
format
:
function
()
{
return
'Just now'
;
},
nextUpdate
:
1
,
// Less than 30 seconds
test
:
(
date
,
now
)
=>
delta
(
date
,
now
)
<
30
*
SECOND
,
formatFn
:
()
=>
'Just now'
,
nextUpdate
:
1
*
SECOND
,
},
{
test
:
lessThanOneMinuteAgo
,
format
:
nSec
,
nextUpdate
:
1
,
// Less than 1 minute
test
:
(
date
,
now
)
=>
delta
(
date
,
now
)
<
1
*
MINUTE
,
formatFn
:
nSec
,
nextUpdate
:
1
*
SECOND
,
},
{
test
:
lessThanOneHourAgo
,
format
:
nMin
,
nextUpdate
:
minute
,
// less than one hour
test
:
(
date
,
now
)
=>
delta
(
date
,
now
)
<
1
*
HOUR
,
formatFn
:
nMin
,
nextUpdate
:
1
*
MINUTE
,
},
{
test
:
lessThanOneDayAgo
,
format
:
nHr
,
nextUpdate
:
hour
,
// less than one day
test
:
(
date
,
now
)
=>
delta
(
date
,
now
)
<
24
*
HOUR
,
formatFn
:
nHr
,
nextUpdate
:
1
*
HOUR
,
},
{
test
:
thisYear
,
format
:
dayAndMonth
,
// this year
test
:
(
date
,
now
)
=>
date
.
getFullYear
()
===
now
.
getFullYear
(),
formatFn
:
dayAndMonth
,
nextUpdate
:
null
,
},
{
test
:
function
()
{
return
true
;
},
format
:
dayAndMonthAndYear
,
// everything else (default case)
test
:
()
=>
true
,
formatFn
:
dayAndMonthAndYear
,
nextUpdate
:
null
,
},
];
...
...
@@ -151,40 +147,47 @@ const BREAKPOINTS = [
*
* @param {Date} date - The date to consider as the timestamp to format.
* @param {Date} now - The date to consider as the current time.
* @return {breakpoint} A dict that describes how to format the date.
* @return {breakpoint|null} An object that describes how to format the date or
* null if no breakpoint matches.
*/
function
getBreakpoint
(
date
,
now
)
{
let
breakpoint
;
for
(
let
i
=
0
;
i
<
BREAKPOINTS
.
length
;
i
++
)
{
breakpoint
=
BREAKPOINTS
[
i
];
for
(
let
breakpoint
of
BREAKPOINTS
)
{
if
(
breakpoint
.
test
(
date
,
now
))
{
return
breakpoint
;
}
}
return
null
;
}
/**
* Return the number of milliseconds until the next update for a given date
* should be handled, based on the delta between `date` and `now`.
*
* @param {Date} date
* @param {Date} now
* @return {Number|null} - ms until next update or `null` if no update
* should occur
*/
function
nextFuzzyUpdate
(
date
,
now
)
{
if
(
!
date
)
{
return
null
;
}
let
secs
=
getBreakpoint
(
date
,
now
).
nextUpdate
;
let
nextUpdate
=
getBreakpoint
(
date
,
now
).
nextUpdate
;
if
(
secs
===
null
)
{
if
(
nextUpdate
===
null
)
{
return
null
;
}
// We don't want to refresh anything more often than 5 seconds
secs
=
Math
.
max
(
secs
,
5
);
nextUpdate
=
Math
.
max
(
nextUpdate
,
5
*
SECOND
);
// setTimeout limit is MAX_INT32=(2^31-1) (in ms),
// which is about 24.8 days. So we don't set up any timeouts
// longer than 24 days, that is, 2073600 seconds.
secs
=
Math
.
min
(
secs
,
2073600
);
nextUpdate
=
Math
.
min
(
nextUpdate
,
2073600
*
SECOND
);
return
secs
;
return
nextUpdate
;
}
/**
...
...
@@ -195,48 +198,55 @@ function nextFuzzyUpdate(date, now) {
* update frequency depends on the age of a timestamp.
*
* @param {String} date - An ISO 8601 date string timestamp to format.
* @param {
Date
} callback - A callback function to call when the timestamp changes.
* @param {
UpdateCallback
} callback - A callback function to call when the timestamp changes.
* @return {Function} A function that cancels the automatic refresh.
*/
function
decayingInterval
(
date
,
callback
)
{
let
timer
;
const
timeStamp
=
date
?
new
Date
(
date
)
:
null
;
const
update
=
function
()
{
const
update
=
()
=>
{
const
fuzzyUpdate
=
nextFuzzyUpdate
(
timeStamp
,
new
Date
());
if
(
fuzzyUpdate
===
null
)
{
return
;
}
const
nextUpdate
=
1000
*
fuzzyUpdate
+
500
;
timer
=
setTimeout
(
function
()
{
const
nextUpdate
=
fuzzyUpdate
+
500
;
timer
=
setTimeout
(
()
=>
{
callback
(
date
);
update
();
},
nextUpdate
);
};
update
();
return
function
()
{
clearTimeout
(
timer
);
};
return
()
=>
clearTimeout
(
timer
);
}
/**
* This callback is a param for the `decayingInterval` function.
* @callback UpdateCallback
* @param {Date} - The date associated with the current interval/timeout
*/
/**
* Formats a date as a string relative to the current date.
*
* @param {Date} date - The date to consider as the timestamp to format.
* @param {Date} now - The date to consider as the current time.
* @param {Object} Intl - JS internationalization API implementation.
* @param {Object} Intl - JS internationalization API implementation; this
* param is present for dependency injection during test.
* @return {string} A 'fuzzy' string describing the relative age of the date.
*/
function
toFuzzyString
(
date
,
now
,
Intl
)
{
if
(
!
date
)
{
return
''
;
}
return
getBreakpoint
(
date
,
now
).
format
(
date
,
now
,
Intl
);
return
getBreakpoint
(
date
,
now
).
format
Fn
(
date
,
now
,
Intl
);
}
module
.
exports
=
{
clearFormatters
:
clearFormatters
,
clearFormatters
:
clearFormatters
,
// For testing
decayingInterval
:
decayingInterval
,
nextFuzzyUpdate
:
nextFuzzyUpdate
,
nextFuzzyUpdate
:
nextFuzzyUpdate
,
// For testing
toFuzzyString
:
toFuzzyString
,
};
src/styles/sidebar/components/annotation-header.scss
View file @
5b672230
...
...
@@ -10,12 +10,29 @@
align-items
:
baseline
;
}
// Timestamps are right aligned in a flex row
&
__timestamp
{
margin-left
:
auto
;
}
&
__timestamp-
link
{
&
__timestamp-
edited
{
@include
font-small
;
font-style
:
italic
;
color
:
$grey-4
;
}
&
__timestamp-created-link
,
&
__timestamp-edited-link
{
&
:hover
{
text-decoration
:
underline
;
}
}
&
__timestamp-created-link
{
color
:
$grey-5
;
}
&
__timestamp-edited-link
{
color
:
$grey-4
;
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment