How to use custom domain for GitHub pages

Issue #423

In DNS settings

Add 4 A records

1
2
3
4
A @ 185.199.110.153
A @ 185.199.111.153
A @ 185.199.108.153
A @ 185.199.109.153

and 1 CNAME record

1
CNAME www learntalks.github.io

In GitHub

  • Select custom domain and type learntalks.com

In source

public/CNAME

1
learntalks.com

How to constrain to views inside UICollectionViewCell in iOS

Issue #422

To constrain views outside to elements inside UICollectionViewCell, we can use UILayoutGuide.

Need to make layout guide the same constraints as the real elements

1
2
3
4
5
6
let imageViewGuide = UILayoutGuide()
collectionView.addLayoutGuide(imageViewGuide)
NSLayoutConstraint.on([
imageViewGuide.topAnchor.constraint(equalTo: collectionView.topAnchor, constant: 16),
imageViewGuide.heightAnchor.constraint(equalTo: collectionView.heightAnchor, multiplier: 0.5)
])
1
2
3
4
NSLayoutConstraint.on([
loadingIndicator.centerXAnchor.constraint(equalTo: collectionView.centerXAnchor),
loadingIndicator.centerYAnchor.constraint(equalTo: imageViewGuide.centerYAnchor)
])

How to secure CVC in STPPaymentCardTextField in Stripe for iOS

Issue #421

1
2
3
4
5
6
7
8
9
10
11
12
private func maskCvcIfAny() {
guard
let view = paymentTextField.subviews.first(where: { !($0 is UIImageView) }),
let cvcField = view.subviews
.compactMap({ $0 as? UITextField })
.first(where: { $0.tag == 2 && ($0.accessibilityLabel ?? "").lowercased().contains("cvc") })
else {
return
}

cvcField.isSecureTextEntry = true
}

where tag is in STPPaymentCardTextFieldViewModel.h

1
2
3
4
5
6
typedef NS_ENUM(NSInteger, STPCardFieldType) {
STPCardFieldTypeNumber,
STPCardFieldTypeExpiration,
STPCardFieldTypeCVC,
STPCardFieldTypePostalCode,
};

Also, need to check accessibilityLabel in STPPaymentCardTextField.m

1
2
3
4
5
6
7
- (NSString *)defaultCVCPlaceholder {
if (self.viewModel.brand == STPCardBrandAmex) {
return STPLocalizedString(@"CVV", @"Label for entering CVV in text field");
} else {
return STPLocalizedString(@"CVC", @"Label for entering CVC in text field");
}
}

How to read and write file using fs in node

Issue #419

1
2
3
4
5
6
7
8
9
10
11
12
function write(json) {
const data = JSON.stringify(json)
const year = json.date.getFullYear()
const directory = `collected/${slugify(className)}/${year}`

fs.mkdirSync(directory, { recursive: true })
fs.writeFileSync(
`${directory}/${slugify(studentName)}`,
data,
{ overwrite: true }
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
async function readAll() {
const classes = fs.readdirSync('classes')
classes.forEach((class) => {
const years = fs.readdirSync(`classes/${class}`)
years.forEach((year) => {
const students = fs.readdirSync(`classes/${class}/${year}`)
students.forEach((student) => {
const data = fs.readFileSync(`classes/${class}/${year}/${student})
const json = JSON.parse(data)
})
})
})
}

How to get videos from vimeo in node

Issue #418

Code

Path for user users/nsspain/videos
Path for showcase https://developer.vimeo.com/api/reference/albums#get_album
Path for Channels, Groups and Portfolios

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const Vimeo = require('vimeo').Vimeo
const vimeoClient = new Vimeo(vimeoClientId, vimeoClientSecret, vimeoAccessToken)

async getVideos(path) {
const options = {
path: `channels/staffpicks/videos`,
query: {
page: 1,
per_page: 100,
fields: 'uri,name,description,created_time,pictures'
}
}

return new Promise((resolve, reject) => {
try {
vimeoClient.request(options, (error, body, status_code, headers) => {
if (isValid(body)) {
resolve(body)
} else {
throw error
}
})
} catch (e) {
reject(e)
console.log(e)
}
})
}

Response look like

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
{
"total":13754,
"page":1,
"per_page":100,
"paging":{
"next":"/channels/staffpicks/videos?page=2&per_page=100&fields=uri%2Cname%2Cdescription%2Ccreated_time%2Cpictures",
"previous":null,
"first":"/channels/staffpicks/videos?page=1&per_page=100&fields=uri%2Cname%2Cdescription%2Ccreated_time%2Cpictures",
"last":"/channels/staffpicks/videos?page=138&per_page=100&fields=uri%2Cname%2Cdescription%2Ccreated_time%2Cpictures"
},
"data":[
{
"uri":"/videos/359281775",
"name":"Maestro",
"description":"A Bloom Pictures short film directed by Illogic.\n\n\"Maestro\" is this week's Staff Pick Premiere. Read more about it on the Vimeo Blog: https://vimeo.com/blog/post/staff-pick-premiere-maestro-from-illogic\n\nMaking of :\n https://vimeo.com/bloompictures/maestromakingof\n\nYou want to collaborate?\nSend us a message at : hello@bloompictures.tv\n\nFor festivals and screenings, please contact : \nfestival@miyu.fr\n\nPress/Media requests : \nbenoit@animationshowcase.com\n\nhttps://www.bloompictures.tv\n\n©Bloom Pictures 2019",
"created_time":"2019-09-11T12:31:33+00:00",
"pictures":{
"uri":"/videos/359281775/pictures/813130850",
"active":true,
"type":"custom",
"sizes":[
{
"width":100,
"height":75,
"link":"https://i.vimeocdn.com/video/813130850_100x75.jpg?r=pad",
"link_with_play_button":"https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F813130850_100x75.jpg&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png"
},
{
"width":200,
"height":150,
"link":"https://i.vimeocdn.com/video/813130850_200x150.jpg?r=pad",
"link_with_play_button":"https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F813130850_200x150.jpg&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png"
},
{
"width":295,
"height":166,
"link":"https://i.vimeocdn.com/video/813130850_295x166.jpg?r=pad",
"link_with_play_button":"https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F813130850_295x166.jpg&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png"
},
{
"width":640,
"height":360,
"link":"https://i.vimeocdn.com/video/813130850_640x360.jpg?r=pad",
"link_with_play_button":"https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F813130850_640x360.jpg&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png"
},
{
"width":1280,
"height":720,
"link":"https://i.vimeocdn.com/video/813130850_1280x720.jpg?r=pad",
"link_with_play_button":"https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F813130850_1280x720.jpg&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png"
},
{
"width":1920,
"height":1080,
"link":"https://i.vimeocdn.com/video/813130850_1920x1080.jpg?r=pad",
"link_with_play_button":"https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F813130850_1920x1080.jpg&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png"
},
{
"width":640,
"height":346,
"link":"https://i.vimeocdn.com/video/813130850_640x346.jpg?r=pad",
"link_with_play_button":"https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F813130850_640x346.jpg&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png"
},
{
"width":960,
"height":519,
"link":"https://i.vimeocdn.com/video/813130850_960x519.jpg?r=pad",
"link_with_play_button":"https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F813130850_960x519.jpg&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png"
},
{
"width":1280,
"height":692,
"link":"https://i.vimeocdn.com/video/813130850_1280x692.jpg?r=pad",
"link_with_play_button":"https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F813130850_1280x692.jpg&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png"
},
{
"width":1920,
"height":1038,
"link":"https://i.vimeocdn.com/video/813130850_1920x1038.jpg?r=pad",
"link_with_play_button":"https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F813130850_1920x1038.jpg&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png"
},
{
"width":1280,
"height":692,
"link":"https://i.vimeocdn.com/video/813130850_1280x692.jpg?r=pad",
"link_with_play_button":"https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F813130850_1280x692.jpg&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png"
}
],
"resource_key":"b42ac645a67b3277cb2fe66d3894016842ceef72"
}
}
]
}

Read more

How to get videos from youtube in node

Issue #417

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Youtube {
async getVideos(playlistId, pageToken) {
const options = {
key: clientKey,
part: 'id,contentDetails,snippet',
playlistId: playlistId,
maxResult: 100,
pageToken
}

return new Promise((resolve, reject) => {
try {
youtube.playlistItems.list(options, (error, result) => {
if (isValid(result)) {
resolve(result)
} else {
throw error
}
})
} catch (e) {
reject(e)
}
})
}
}

Response look like

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
{
"kind": "youtube#playlistItemListResponse",
"etag": "\"p4VTdlkQv3HQeTEaXgvLePAydmU/ZNTrH71d3sV6gR6BWPeamXI1HhE\"",
"nextPageToken": "CAUQAA",
"pageInfo": {
"totalResults": 32,
"resultsPerPage": 5
},
"items": [
{
"kind": "youtube#playlistItem",
"etag": "\"p4VTdlkQv3HQeTEaXgvLePAydmU/pt-bElhU3f7Q6c1Wc0URk9GJN-w\"",
"id": "UExDbDVOTTRxRDN1X0w4ZEpyV1liTEI4RmNVYW9BSERGdC4yODlGNEE0NkRGMEEzMEQy",
"snippet": {
"publishedAt": "2019-04-11T06:09:26.000Z",
"channelId": "UCuPue-GLK4nVX8klxQITIOw",
"title": "abc",
"description": "abc",
"thumbnails": {
"default": {
"url": "https://i.ytimg.com/vi/ZefmzgLabCA/default.jpg",
"width": 120,
"height": 90
},
"medium": {
"url": "https://i.ytimg.com/vi/ZefmzgLabCA/mqdefault.jpg",
"width": 320,
"height": 180
},
"high": {
"url": "https://i.ytimg.com/vi/ZefmzgLabCA/hqdefault.jpg",
"width": 480,
"height": 360
},
"standard": {
"url": "https://i.ytimg.com/vi/ZefmzgLabCA/sddefault.jpg",
"width": 640,
"height": 480
},
"maxres": {
"url": "https://i.ytimg.com/vi/ZefmzgLabCA/maxresdefault.jpg",
"width": 1280,
"height": 720
}
},
"channelTitle": "try! Swift Conference",
"playlistId": "PLCl5NM4qD3u_L8dJrWYbLB8FcUaoAHDFt",
"position": 0,
"resourceId": {
"kind": "youtube#video",
"videoId": "ZefmzgLabCA"
}
}
}
]
}

To handle pagination

1
2
3
4
5
6
7
8
9
async getVideosLoop(playlistId, nextPageToken, items, count) {
const response = await this.getVideos(playlistId, nextPageToken)
const newItems = items.concat(response.data.items)
if (isValid(response.data.nextPageToken) && count < 10) {
return this.getVideosLoop(playlistId, response.data.nextPageToken, newItems, count + 1)
} else {
return newItems
}
}

To get playlist title, use playlists.list

Read more

How to convert callback to Promise in Javascript

Issue #416

1
2
3
4
5
6
7
8
9
10
11
12
13
// @flow

const toPromise = (f: (any) => void) => {
return new Promise<any>((resolve, reject) => {
try {
f((result) => {
resolve(result)
})
} catch (e) {
reject(e)
}
})
}
1
const videos = await toPromise(callback)

If a function accepts many parameters, we need to curry https://onmyway133.github.io/blog/Curry-in-Swift-and-Javascript/

1
2
3
4
5
6
7
8
9
10
function curry2(f) {
return (p1) => {
return (p2) => {
return f(p1, p2)
}
}
}

const callback = curry2(aFunctionThatAcceptsOptionsAndCallback)(options)
const items = await toPromise(callback)

How to fix electron issues

Issue #415

Electron require() is not defined

https://stackoverflow.com/questions/44391448/electron-require-is-not-defined

1
2
3
4
5
6
7
8
9
10
11
12
function createWindow () {
win = new BrowserWindow({
title: 'MyApp',
width: 600,
height: 500,
resizable: false,
icon: __dirname + '/Icon/Icon.icns',
webPreferences: {
nodeIntegration: true
}
})
}

DevTools was disconnected from the page

1
2
npm install babel-cli@latest --save-dev
npm install react@16.2.0
1
win.openDevTools()

This leads to Cannot find module 'react/lib/ReactComponentTreeHook'
If we’re using binary, then rebuild, it is the problem that cause devTools not work

1
npx electron-builder

This goes to Cannot read property injection of undefined at react-tap-event-plugin

https://github.com/zilverline/react-tap-event-plugin/issues/121

1
npm uninstall react-tap-event-plugin

This goes to Unknown event handler property onTouchTap in EnhancedButton in material-ui

Update material-ui

https://material-ui.com/
https://material-ui.com/guides/migration-v0x/#raised-button

1
npm install @material-ui/core

From

1
2
3
4
import RadioButtonGroup from '@material-ui/core/RadioButton/RadioButtonGroup'
import RadioButton from '@material-ui/core/RadioButton'
import RaisedButton from '@material-ui/core/RaisedButton'
import CardText from '@material-ui/core/Card/CardText'

to

1
2
3
4
import RadioButtonGroup from '@material-ui/core/RadioGroup'
import RadioButton from '@material-ui/core/Radio'
import RaisedButton from '@material-ui/core/Button'
import CardText from '@material-ui/core/DialogContentText'

Use FormControlLabel https://material-ui.com/components/radio-buttons/

1
2
3
4
5
6
7
<RadioGroup 
style={styles.group}
defaultselected={this.state.choice}
onChange={this.handleChoiceChange}
children={choiceElements} />
{this.makeGenerateButton()}
/>

How to easily parse deep json in Swift

Issue #414

Codable is awesome, but sometimes we just need to quickly get value in a deepy nested JSON. In the same way I did for Dart How to resolve deep json object in Dart, let’s make that in Swift.

See https://github.com/onmyway133/Omnia/blob/master/Sources/Shared/JSON.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
public func resolve<T>(_ jsonDictionary: [String: Any], keyPath: String) -> T? {
var current: Any? = jsonDictionary

keyPath.split(separator: ".").forEach { component in
if let maybeInt = Int(component), let array = current as? Array<Any> {
current = array[maybeInt]
} else if let dictionary = current as? JSONDictionary {
current = dictionary[String(component)]
}
}

return current as? T
}

So we can just resolve via key path

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class JSONTests: XCTestCase {
func test() {
let json: [String: Any] = [
"outside": [
"object": [
"number": 1,
"text": "hello"
],
"arrayOfObjects": [
[
"number": 2,
"text": "two"
],
[
"number": 3,
"text": "three"
]
],
"arrayOfArrays": [
[
"one", "two", "three", "four"
],
[
"five", "six", "seven"
]
]
]
]

XCTAssertEqual(resolve(json, keyPath: "outside.object.number"), 1)
XCTAssertEqual(resolve(json, keyPath: "outside.object.text"), "hello")
XCTAssertEqual(resolve(json, keyPath: "outside.arrayOfObjects.1.number"), 3)
XCTAssertEqual(resolve(json, keyPath: "outside.arrayOfArrays.1.1"), "six")
}
}

How to speed up GMSMarker in Google Maps for iOS

Issue #412

  • Google Maps with a lot of pin, and no clustering can have bad performance if there are complex view in the marker.
  • The workaround is to use manual layout and rasterization

shouldRasterize

When the value of this property is true, the layer is rendered as a bitmap in its local coordinate space and then composited to the destination with any other content. Shadow effects and any filters in the filters property are rasterized and included in the bitmap. However, the current opacity of the layer is not rasterized. If the rasterized bitmap requires scaling during compositing, the filters in the minificationFilter and magnificationFilter properties are applied as needed.

In the class PinView: UIView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
isOpaque = true
layer.shouldRasterize = true
layer.rasterizationScale = UIScreen.main.scale

final class StopMarker: GMSMarker {
let stop: Stop
private let pinView = PinView()

init(stop: Stop) {
self.stop = stop
super.init()
self.position = stop.toCoordinate()
self.iconView = pinView
}
}

Read more

When your app needs to draw something on the screen, the GPU takes your layer hierarchy (UIView is just a wrapper on top of CALayer, which in the end are OpenGL textures) and applies one by one on top of each other based on their x,y,z position. In regular rendering, the whole operation happens in special frame buffers that the display will directly read for rendering on the screen, repeating the process at a rate around 60 times per second.

Of course the process have some drawbacks as well. The main one is that offscreen rendering requires a context switch (GPU has to change to a different memory area to perform the drawing) and then copying the resulting composited layer into the frame buffer. Every time any of the composited layers change, the cache needs to be redrawn again. This is why in many circumstances offscreen rendering is not a good idea, as it requires additional computation when need to be rerendered. Besides, the layer requires extra video memory which of course is limited, so use it with caution.

How to support drag and drop in UICollectionView iOS

Issue #411

See DragAndDrop example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class ViewController: UIViewController, UICollectionViewDropDelegate, UICollectionViewDragDelegate {

// MARK: - UICollectionViewDragDelegate

func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
let controller = leftController

let provider = NSItemProvider(
object: controller.imageForCell(indexPath: indexPath)
)

let dragItem = UIDragItem(itemProvider: provider)
dragItem.localObject = indexPath
return [dragItem]
}

// MARK: - UICollectionViewDropDelegate

func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {

let destinationIndexPath: IndexPath
if let indexPath = coordinator.destinationIndexPath {
destinationIndexPath = indexPath
} else {
destinationIndexPath = IndexPath(row: 0, section: 0)
}

let controller = rightController

let dragItemIndexPath = coordinator.items.last?.dragItem.localObject as! IndexPath
let draggedItem = leftController.items[dragItemIndexPath.item]

// remove
leftController.items.remove(at: dragItemIndexPath.item)
leftController.collectionView.deleteItems(at: [dragItemIndexPath])

// insert
controller.items.insert(draggedItem, at: destinationIndexPath.item)
controller.collectionView.insertItems(at: [destinationIndexPath])
}
}

How to support drag and drop in NSView

Issue #410

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import AppKit
import Anchors

class DraggingView: NSView {
var didDrag: ((FileInfo) -> Void)?
let highlightView = NSView()

override init(frame frameRect: NSRect) {
super.init(frame: frameRect)

registerForDraggedTypes([
.fileURL
])

highlightView.isHidden = true
addSubview(highlightView)
activate(highlightView.anchor.edges)
highlightView.wantsLayer = true
highlightView.layer?.borderColor = NSColor(hex: "#FF6CA8").cgColor
highlightView.layer?.borderWidth = 6
}

required init?(coder decoder: NSCoder) {
fatalError()
}

override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
highlightView.isHidden = false
return NSDragOperation()
}

override func draggingEnded(_ sender: NSDraggingInfo) {
guard let pathAlias = sender.draggingPasteboard.propertyList(forType: .fileURL) as? String else {
return
}

let url = URL(fileURLWithPath: pathAlias).standardized
let fileInfo = FileInfo(url: url)
didDrag?(fileInfo)
}

override func draggingExited(_ sender: NSDraggingInfo?) {
highlightView.isHidden = true
}

override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
return NSDragOperation()
}
}

To get information about multiple files

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
override func draggingEnded(_ sender: NSDraggingInfo) {
guard let pasteBoardItems = sender.draggingPasteboard.pasteboardItems else {
return
}

let fileInfos: [FileInfo] = pasteBoardItems
.compactMap({
return $0.propertyList(forType: .fileURL) as? String
})
.map({
let url = URL(fileURLWithPath: $0).standardized
return FileInfo(url: url)
})

didDrag(fileInfos)
}

How to use NSStepper in Appkit

Issue #409

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let stepper = NSStepper()
let textField = NSTextField(wrappingLabelWithString: "\(myLocalCount)")

stepper.integerValue = myLocalCount
stepper.minValue = 5
stepper.maxValue = 24
stepper.valueWraps = false

stepper.target = self
stepper.action = #selector(onStepperChange(_:))

@objc func onStepperChange(_ sender: NSStepper) {
myLocalCount = sender.integerValue
textField.stringValue = "\(sender.integerValue)"
}

How to handle shortcut in AppKit

Issue #408

Podfile

1
pod 'MASShortcut'
1
2
3
4
5
let shortcut = MASShortcut(keyCode: kVK_ANSI_K, modifierFlags: [.command, .shift])

MASShortcutMonitor.shared()?.register(shortcut, withAction: {
self.showPopover(sender: self.statusItem.button)
})

How to select file in its directory in AppKit

Issue #407

https://developer.apple.com/documentation/appkit/nsworkspace/1524399-selectfile

In macOS 10.5 and later, this method does not follow symlinks when selecting the file. If the fullPath parameter contains any symlinks, this method selects the symlink instead of the file it targets. If you want to select the target file, use the resolvingSymlinksInPath method to resolve any symlinks before calling this method.

It is safe to call this method from any thread of your app.

1
2
3
NSWorkspace.shared.selectFile(
url.path,
inFileViewerRootedAtPath: url.deletingLastPathComponent().path)

How to use NSProgressIndicator in AppKit

Issue #406

1
2
3
4
let progressIndicator = NSProgressIndicator()
progressIndicator.isIndeterminate = true
progressIndicator.style = .spinning
progressIndicator.startAnimation(nil)

How to show save panel in AppKit

Issue #405

Enable Read/Write for User Selected File under Sandbox to avoid bridge absent error

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func save() {
let panel = NSSavePanel()
// 3
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser
// 4
panel.nameFieldStringValue = "abc.gif"

// 5
guard let window = view.window else {
return
}

panel.beginSheetModal(for: window) { (result) in
guard result == .OK, let url = panel.url else {
self.showAlert()
return
}
}
}

func showAlert() {
let alert = NSAlert()
alert.messageText = "Hello world"
alert.informativeText = "Information text"
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
alert.runModal()
}

To save multiple files, use NSOpenPanel

1
2
3
4
5
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.canChooseDirectories = true
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser

Read more

How to animate NSView using keyframe

Issue #404

1
2
3
4
5
6
7
8
let animation = CAKeyframeAnimation(keyPath: "position.y")
animation.values = [50, 20, 50]
animation.keyTimes = [0.0, 0.5, 1.0]
animation.duration = 2
animation.repeatCount = Float.greatestFiniteMagnitude
animation.autoreverses = true
myView.wantsLayer = true
myView.layer?.add(animation, forKey: "bounce")

How to quit macOS on last window closed

Issue #403

https://developer.apple.com/documentation/appkit/nsapplicationdelegate/1428381-applicationshouldterminateafterl?language=objc

The application sends this message to your delegate when the application’s last window is closed. It sends this message regardless of whether there are still panels open. (A panel in this case is defined as being an instance of NSPanel or one of its subclasses.)

If your implementation returns NO, control returns to the main event loop and the application is not terminated. If you return YES, your delegate’s applicationShouldTerminate: method is subsequently invoked to confirm that the application should be terminated.

1
2
3
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}

How to test Date with timezone aware in Swift

Issue #402

I want to test if a date has passed another date

1
2
let base =  Date(timeIntervalSince1970: 1567756697)
XCTAssertEqual(validator.hasPassed(event: event, date: base), true)

My hasPassed is using Calendar.current

1
2
3
4
5
func minuteSinceMidnight(date: Date) -> MinuteSinceMidnight {
let calendar = Calendar.current
let start = calendar.startOfDay(for: date)
return Int(date.timeIntervalSince(start)) / 60
}

But the minute is always having timezone applied. Even if I try with DateComponents

1
2
3
4
5
6
7
8
func minuteSinceMidnight(date: Date) -> MinuteSinceMidnight {
let components = calendar.dateComponents([.hour, .minute], from: date)
guard let hour = components.hour, let minute = components.minute else {
return 0
}

return hour * 60 + minute
}

As long as I use Calendar, it always has timezone applied.

Checking this time interval 1567756697 on https://www.epochconverter.com/

Assuming that this timestamp is in seconds:
GMT: Friday, September 6, 2019 7:58:17 PM
Your time zone: Friday, September 6, 2019 9:58:17 PM GMT+02:00 DST

Because I have GMT+2, there will always be 2 hours offset. This works in app, but not in test because of the way I construct Date with time interval.

One way is to have test data using string construction, and provide timezone to DateFormatter

1
2
3
let formatter = ISO8601DateFormatter()
let date = formatter.date(from: "2019-07-58T12:39:00Z")
let string = formatter.string(from: Date())

Another way is to have a fixed timezone for Calendar

1
2
var calendar = Calendar.current
calendar.timeZone = TimeZone(secondsFromGMT: 0)!

Another way is to adjust existing date

1
calendar.date(bySettingHour: 20, minute: 02, second: 00, of: Date()

How to sign executable for sandbox

Issue #401

Find identity

1
security find-identity

Sign with entitlements and identity. For macOS, use 3rd Party Mac Developer Application

1
codesign -f -s "3rd Party Mac Developer Application: Khoa Pham (123DK123F2)" --entitlements "MyApp.entitlements" "tool/mytool"

To enable harden runtime

1
codesign --verbose --force --deep -o runtime --sign

How to make collaborative drawing canvas with socketio and node

Issue #399

Client

App.js

1
2
3
4
5
6
7
8
9
10
11
12
import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';
import Main from './Main'

class App extends Component {
render() {
return <Main />
}
}

export default App;

Main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// @flow

import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
import IconButton from '@material-ui/core/IconButton';
import MenuIcon from '@material-ui/icons/Menu';

import Manager from './Manager'

const styles = {
root: {
flexGrow: 1,
},
grow: {
flexGrow: 1,
},
menuButton: {
marginLeft: -12,
marginRight: 20,
},
};

class Main extends React.Component {
static propTypes = {
classes: PropTypes.object.isRequired,
}

render() {
const { classes } = this.props;

return (
<div className={classes.root}>
<AppBar position="static">
<Toolbar>
<IconButton className={classes.menuButton} color="inherit" aria-label="Menu">
<MenuIcon />
</IconButton>
<Typography variant="h6" color="inherit" className={classes.grow}>
Collaborate Canvas
</Typography>
<Button color="inherit" onClick={this.onImagePress} >Image</Button>
<Button color="inherit" onClick={this.onClearPress} >Clear</Button>
<input ref="fileInput" type="file" id="myFile" multiple accept="image/*" style={{display: 'none'}} onChange={this.handleFiles}></input>
</Toolbar>
</AppBar>
<canvas ref="canvas" with="1000" height="1000"></canvas>
</div>
)
}

componentDidMount() {
const canvas = this.refs.canvas
this.manager = new Manager(canvas)
this.manager.connect()
}

onImagePress = () => {
const fileInput = this.refs.fileInput
fileInput.click()
}

onClearPress = () => {
this.manager.clear()
}

handleFiles = (e) => {
e.persist()
const canvas = this.refs.canvas
const context = canvas.getContext('2d')

const file = e.target.files[0]
var image = new Image()
image.onload = function() {
context.drawImage(image, 0, 0, window.innerWidth, window.innerHeight)
}
image.src = URL.createObjectURL(file)
}
}

export default withStyles(styles)(Main);

Server

Use express and socket.io

index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// @flow

const express = require('express')
const app = express()
const http = require('http')
const socketIO = require('socket.io')

const server = http.createServer(app)
const io = socketIO.listen(server)
server.listen(3001)
app.use(express.static(__dirname + '/public'))
console.log("Server running on 127.0.0.1:8080")

let lines = []
io.on('connection', (socket) => {
lines.forEach((line) => {
const data = { line }
socket.emit('draw_line', data)
})

socket.on('draw_line', (data) => {
const { line } = data
lines.push(line)

io.emit('draw_line', data)
})

socket.on('clear', () => {
lines = []
io.emit('clear')
})

socket.on('draw_image', (data) => {
io.emit('draw_image', data)
})
})

How to generate changelog for GitHub releases with rxjs and node

Issue #398

How to

Technical

Dependencies

1
2
3
4
const Rx = require('rxjs/Rx')
const Fetch = require('node-fetch')
const Minimist = require('minimist')
const Fs = require('fs')

Use GraphQL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
makeOptions(query, token) {
return {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `bearer ${token}`
},
body: JSON.stringify({
query: `
query {
repository(owner: "${this.owner}", name: "${this.repo}") {
${query}
}
}
`
})
}
}

Use orderBy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
fetchPRsAndIssues(dates) {
const query = `
pullRequests(last: 100, orderBy: {field: UPDATED_AT, direction: ASC}) {
edges {
node {
title
merged
mergedAt
url
author {
login
url
}
}
}
}
issues(last: 100, orderBy: {field: UPDATED_AT, direction: ASC}) {
edges {
node {
title
closed
updatedAt
url
}
}
}
}
}

How to do launch screen in Android

Issue #397

We recommend that, rather than disabling the preview window, you follow the common Material Design patterns. You can use the activity’s windowBackground theme attribute to provide a simple custom drawable for the starting activity.

styles.xml

1
2
3
<style name="LaunchTheme" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">@drawable/launch_background</item>
</style>

Set android:theme="@style/LaunchTheme" to activity element

AndroidManifest.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.onmyway133.whatsupintech">
<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".MyApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".features.main.MainActivity"
android:label="@string/app_name"
android:theme="@style/LaunchTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>

</manifest>

MainActivity.kt

1
2
3
4
5
6
7
8
class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
setTheme(R.style.AppTheme_NoActionBar)
super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity)
}
}

Read more

How to add header to NavigationView in Android

Issue #396

Use app:headerLayout

1
2
3
4
5
6
7
8
9
10
<com.google.android.material.navigation.NavigationView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/navigationView"
android:fitsSystemWindows="true"
android:layout_gravity="start"
app:menu="@menu/drawer_menu"
app:itemIconTint="@color/title"
app:itemTextColor="@color/title"
app:headerLayout="@layout/navigation_header" />

navigation_header.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:src="@drawable/icon"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

How to do simple analytics in iOS

Issue #395

Prefer static enum to avoid repetition and error. The Log should have methods with all required fields so the call site is as simple as possible. How to format and assign parameters is encapsulated in this Analytics.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import Foundation
import Firebase
import FirebaseAnalytics

struct Analytics {
enum Parameter: String {
case studentId = "student_id"
case classId = "class_id"
case url = "url"
}

enum Property: String {
case grantLocation = "grant_location"
}

enum Name: String {
case login
case logOut = "log_out"
case enroll
}

struct Log {
private func log(_ name: Name, parameters: [Parameter: String] = [:]) {
let mapped: [String: String] = Dictionary(uniqueKeysWithValues: parameters.map({ key, value in
return (key.rawValue, value)
}))

FirebaseAnalytics.Analytics.logEvent(name.rawValue, parameters: mapped)
}

private func set(userId: String?) {
FirebaseAnalytics.Analytics.setUserID(userId)
}

private func setProperty(_ property: Property, value: String) {
FirebaseAnalytics.Analytics.setUserProperty(value, forName: property.rawValue)
}
}

let log = Log()
}

extension Analytics.Log {
func grantLocation(hasGranted: Bool) {
setProperty(.grantLocation, value: hasGranted.toString())
}

func login(userId: String) {
log(.login)
set(userId: userId)
}

func logOut() {
log(.logOut)
set(userId: nil)
}

func enroll(classId: String) {
log(.enroll, parameters: [
.classId: classId
])
}
}

private extension Bool {
func toString() -> String {
return self ? "yes": "no"
}
}

Back to static site

Issue #394

It’s been a while since I wrote Hello world, again, the ease of GitHub issue indeed motivates me to write more.

In the mean time I also wrote on https://medium.com/@onmyway133 and https://dev.to/onmyway133 and got some traction.

Then I started using GitHub pages again, with Jekyll and remote theme, it works great. But then I needed to manually link the GitHub issues to my page, that’s just labor work.

The best combo is to have a GitHub page backed by GitHub issue. After a bit comparison between different static site generators, I actually tried them all, I chose Hexo because I simply like Javascript

I use the simple cactus theme for now with local searched power by https://www.npmjs.com/package/hexo-generator-search.

Then I wrote a node.js script to mirror my GitHub issue to my page, with correct tags and updated date.

If you by any chance visit my new page https://onmyway133.github.io/, ohayou from me 👋