Skip to content

Instantly share code, notes, and snippets.

@tbekaert
Last active April 10, 2024 22:03
Show Gist options
  • Save tbekaert/5a53ea72f1435bff189a4bb836cd34fa to your computer and use it in GitHub Desktop.
Save tbekaert/5a53ea72f1435bff189a4bb836cd34fa to your computer and use it in GitHub Desktop.
[Scriptable] Linear inbox widget

[Scriptable] Linear inbox widget

This script allow to display your Linear inbox in a widget on your iOS device.

Each item in the list of the medium and large widgets can be clicked on, as well as the widget title.

Widget sizes

small medium large
small medium large

How to install the widgets in Scriptable

  1. Create a new widget in Scriptable and paste the code of the Linear inbox widget.js file
  2. Create a linear API token
  3. Replace YOUR_API_KEY_HERE on the first line of the script with your api key
  4. Choose the size you want the widget to be displayed in on line 2
  5. Save your script
  6. You're good to go 🚀
const LINEAR_API_KEY = "YOUR_API_KEY_HERE";
const WIDGET_SIZE = "medium"; // "small" | "medium" | "large"
let widget = await createWidget();
if (config.runsInWidget) {
Script.setWidget(widget);
} else {
switch (WIDGET_SIZE) {
case "large": {
widget.presentLarge();
break;
}
case "small": {
widget.presentSmall();
break;
}
default: {
widget.presentMedium();
break;
}
}
}
Script.complete();
async function createWidget() {
let w = new ListWidget();
let backgroundGradient = new LinearGradient();
backgroundGradient.colors = [new Color("#29349A"), new Color("#5F69CA")];
backgroundGradient.locations = [0, 1];
backgroundGradient.startPoint = new Point(0, 1);
backgroundGradient.endPoint = new Point(1, 0);
w.backgroundGradient = backgroundGradient;
let main = w.addStack();
main.layoutVertically();
main.topAlignContent();
let headerStack = main.addStack();
headerStack.centerAlignContent();
let headingLeft = headerStack.addText("Linear inbox");
headingLeft.leftAlignText();
headingLeft.font = Font.boldSystemFont(18);
headingLeft.textColor = new Color("#ffffff");
headingLeft.url = 'https://linear.app'
if (WIDGET_SIZE !== "small") {
headerStack.addSpacer();
let headingRight = headerStack.addText(`Last ${getNotificationListSize()} notifications`);
headingRight.leftAlignText();
headingRight.font = Font.regularSystemFont(14);
headingRight.textColor = new Color("#cccccc");
}
main.addSpacer(7);
const data = await getNotifications();
if (WIDGET_SIZE === "small") {
const unreadNotifications = data.filter(({ isRead }) => !isRead).length;
let unreadTitle = main.addText(String(unreadNotifications));
unreadTitle.leftAlignText();
unreadTitle.font = Font.boldSystemFont(45);
unreadTitle.textColor = new Color("#ffffff");
unreadTitle.url = 'https://linear.app/inbox'
let unreadSubtitle = main.addText("unread notifications");
unreadSubtitle.leftAlignText();
unreadSubtitle.font = Font.regularSystemFont(14);
unreadSubtitle.textColor = new Color("#cccccc");
} else {
let listStack = main.addStack();
listStack.layoutVertically();
listStack.spacing = 2;
data.forEach((notification, index) => {
let notificationStack = listStack.addStack();
notificationStack.layoutVertically();
notificationStack.spacing = 0;
notificationStack.url = notification.url
let titleStack = notificationStack.addStack();
titleStack.layoutHorizontally();
if (!notification.isRead) {
let bullet = titleStack.addText("• ");
bullet.leftAlignText();
bullet.font = Font.semiboldSystemFont(14);
bullet.textColor = new Color("#ffffff");
bullet.lineLimit = 1;
}
let title = titleStack.addText(notification.title);
title.leftAlignText();
title.font = Font.semiboldSystemFont(14);
title.textColor = new Color(notification.isRead ? "#aaaaaa" : "#ffffff");
title.lineLimit = 1;
let subtitleStack = notificationStack.addStack();
subtitleStack.spacing = 0;
let idStack = subtitleStack.addStack();
idStack.size = new Size(80, 18);
let id = idStack.addText(notification.identifier);
id.leftAlignText();
id.font = Font.regularSystemFont(12);
id.textColor = new Color(notification.isRead ? "#999999" : "#cccccc");
idStack.addSpacer();
let event = subtitleStack.addText(notification.event);
event.leftAlignText();
event.font = Font.lightSystemFont(12);
event.textColor = new Color(notification.isRead ? "#aaaaaa" : "#ffffff");
event.lineLimit = 1;
});
}
return w;
}
async function getNotifications() {
const request = new Request("https://api.linear.app/graphql");
request.method = "POST";
request.headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${LINEAR_API_KEY}`,
};
request.body = JSON.stringify({
query: `query {
notifications(orderBy: updatedAt) {
nodes {
... on IssueNotification {
type
actor { displayName }
issue {
title
state { name }
identifier
}
team {
organization {
urlKey
}
}
readAt
}
}
}
}`,
variables: {},
});
const response = await request.loadJSON();
const formatedNotifications = response.data.notifications.nodes
.reduce((acc, notification, index) => {
const identifier = notification.issue.identifier;
const initialIndex = response.data.notifications.nodes.findIndex(
(n) => n.issue.identifier === identifier
);
const shouldKeep = index === initialIndex;
if (shouldKeep) {
acc.push({
title: notification.issue.title,
identifier: notification.issue.identifier,
event: getEventLabel(notification.type, {
actor: notification.actor
? notification.actor.displayName
: "Linear",
status: notification.issue.state.name,
}),
url: `https://linear.app/${notification.team.organization.urlKey}/issue/${notification.issue.identifier}`,
isRead: notification.readAt !== null,
});
}
return acc;
}, [])
const notificationListSize = getNotificationListSize()
if (notificationListSize) {
return formatedNotifications.slice(0, getNotificationListSize());
} else {
return formatedNotifications;
}
}
function getEventLabel(event, { actor, status }) {
switch (event) {
case "issueCommentMention": {
return `Mentionned in a comment by ${actor}`;
}
case "issueNewComment": {
return `New comment from ${actor}`;
}
case "issueStatusChanged": {
return `Status changed to ${status}`;
}
case "issueDue": {
return `Overdue`;
}
default: {
return `"${event}" by ${actor}`;
}
}
}
function getNotificationListSize() {
switch (WIDGET_SIZE) {
case "large": {
return 7;
}
case "small": {
return false;
}
default: {
return 3;
}
}
}
@tbekaert
Copy link
Author

Comment to upload the images

large
medium
small

@Tehnix
Copy link

Tehnix commented Apr 10, 2024

A minor adjustment when using Personal API Keys from Linear, remove the Bearer on line 137, otherwise the response will return an error (which will explain to remove it 😁)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment