Compare commits

22 Commits

Author SHA1 Message Date
ad008bf4dd chore: update dependencies 2025-01-17 10:18:02 +01:00
fc577741de Merge branch 'contributions/merge-1734379598380' 2024-12-16 21:06:39 +01:00
98cbbf4814 Pull contributions 2024-12-16 21:06:38 +01:00
560ec86aff chore: Release v0.2.0 2024-12-16 21:02:18 +01:00
33dc17483a feat: Add support for Slack reaction and message notifications 2024-12-16 20:44:09 +01:00
735b2f5481 fix: Trim trailing slash in Universal Inbox URL 2024-04-29 17:29:37 +02:00
a8cf51ec4a chore: Release v0.1.4 2024-03-13 15:55:16 +01:00
4afa06a1c8 fix: Fix Slack logo 2024-03-13 09:02:55 +01:00
8d49154be3 feat: Select Inbox project by default when creating a task 2024-03-13 08:48:33 +01:00
751ee38d32 feat: Add Slack notifications (save for later) support 2024-03-13 08:48:27 +01:00
8a7eda4f3c feat: Add Linear Project and Team icons 2024-02-05 15:27:20 +01:00
1084559688 fix: Add missing action icons 2024-02-05 14:25:57 +01:00
84f4d0bd53 feat: Display Linear project on notification item 2024-02-05 13:58:53 +01:00
a17b259e50 feat: Display Linear notification reason 2024-02-03 08:33:30 +01:00
ad43bf3c86 feat: Add "Plan task" action for notifications created from task 2024-02-02 08:47:22 +01:00
cfc0f0ed5e fix: Publish the extension as a user 2024-02-01 14:20:41 +01:00
9fb5af87ed feat: Add filter per notification kind 2024-02-01 09:37:40 +01:00
26f346f8b8 chore: Prepare for publishing the extension 2024-02-01 09:36:08 +01:00
ea40258f48 feat: Add Todoist notification list item 2024-01-27 00:09:50 +01:00
e0f90b0c42 feat: Add Google Mail thread list item 2024-01-26 22:50:13 +01:00
3df827ebd7 feat: Add Linear notification list item 2024-01-26 21:22:47 +01:00
0507722ecf feat: Add notification actions 2024-01-26 08:48:27 +01:00
74 changed files with 6832 additions and 871 deletions

View File

@@ -1,4 +1,10 @@
{
"root": true,
"extends": ["@raycast"]
"extends": ["@raycast", "plugin:import/recommended", "plugin:import/typescript"],
"settings": {
"import/resolver": {
"typescript": true,
"node": true
}
}
}

View File

@@ -1,4 +1,5 @@
{
"printWidth": 120,
"singleQuote": false
"singleQuote": false,
"plugins": ["./node_modules/prettier-plugin-sort-imports/dist/index.js"]
}

View File

@@ -1,3 +1,63 @@
# Universal Inbox Changelog
# Universal Inbox extension for Raycast Changelog
## [Initial Version] - 2024-01-19
## [Unreleased]
## [0.2.0] - 2024-12-16
### Added
- Add support for Slack reaction notifications
- Add support for Slack message notifications
## [0.1.4] - 2024-03-13
### Added
- Add Linear Project and Team icons
- Add Slack notifications (ie. save for later) support
- Set `Inbox` project as default when creating or planning a task
## [0.1.3] - 2024-02-05
### Added
- Display Linear notification reason
- Display Linear project on notification item
- Add missing action icons
## [0.1.2] - 2024-02-01
### Added
- Add "Plan task" action for notification created from task
### Fixed
- Fix "Complete task" action
## [0.1.1] - 2024-02-01
### Added
- Add filter per notification kind
## [0.1.0] - 2024-01-29
### Added
- Support listing notifications from
- Github Pull Requests
- Github Discussions
- Linear Issues
- Linear Projects
- Google Mail
- Todoist tasks
- Act on notifications
- Open in Browser
- Delete notification
- Unsubscribe from notification
- Snooze notification
- Create a task from notification
- Link notification to an existing task
- Act on tasks in the notification list
- Complete task

View File

@@ -1,3 +1,18 @@
# Universal Inbox
Manage Universal Inbox notifications from Raycast
Manage your notifications in a single [Universal Inbox](https://www.universal-inbox.com)
## Setup
To use the Universal Inbox extension for Raycast, you will need to configure an instance URL and an API key.
### Instance URL
The default instance URL is: https://app.universal-inbox.com
Get the URL from the Universal Inbox instance you are usually connecting to.
### API Key
You can get an API Key from your Universal Inbox user profile page:
![user profile page](media/ui-user-profile-page.png)

View File

@@ -0,0 +1 @@
<svg width="14" height="14" viewBox="0 0 14 14" aria-label="Backlog" fill="#D2D3E0"><path d="M13.9408 7.91426L11.9576 7.65557C11.9855 7.4419 12 7.22314 12 7C12 6.77686 11.9855 6.5581 11.9576 6.34443L13.9408 6.08573C13.9799 6.38496 14 6.69013 14 7C14 7.30987 13.9799 7.61504 13.9408 7.91426ZM13.4688 4.32049C13.2328 3.7514 12.9239 3.22019 12.5538 2.73851L10.968 3.95716C11.2328 4.30185 11.4533 4.68119 11.6214 5.08659L13.4688 4.32049ZM11.2615 1.4462L10.0428 3.03204C9.69815 2.76716 9.31881 2.54673 8.91341 2.37862L9.67951 0.531163C10.2486 0.767153 10.7798 1.07605 11.2615 1.4462ZM7.91426 0.0591659L7.65557 2.04237C7.4419 2.01449 7.22314 2 7 2C6.77686 2 6.5581 2.01449 6.34443 2.04237L6.08574 0.059166C6.38496 0.0201343 6.69013 0 7 0C7.30987 0 7.61504 0.0201343 7.91426 0.0591659ZM4.32049 0.531164L5.08659 2.37862C4.68119 2.54673 4.30185 2.76716 3.95716 3.03204L2.73851 1.4462C3.22019 1.07605 3.7514 0.767153 4.32049 0.531164ZM1.4462 2.73851L3.03204 3.95716C2.76716 4.30185 2.54673 4.68119 2.37862 5.08659L0.531164 4.32049C0.767153 3.7514 1.07605 3.22019 1.4462 2.73851ZM0.0591659 6.08574C0.0201343 6.38496 0 6.69013 0 7C0 7.30987 0.0201343 7.61504 0.059166 7.91426L2.04237 7.65557C2.01449 7.4419 2 7.22314 2 7C2 6.77686 2.01449 6.5581 2.04237 6.34443L0.0591659 6.08574ZM0.531164 9.67951L2.37862 8.91341C2.54673 9.31881 2.76716 9.69815 3.03204 10.0428L1.4462 11.2615C1.07605 10.7798 0.767153 10.2486 0.531164 9.67951ZM2.73851 12.5538L3.95716 10.968C4.30185 11.2328 4.68119 11.4533 5.08659 11.6214L4.32049 13.4688C3.7514 13.2328 3.22019 12.9239 2.73851 12.5538ZM6.08574 13.9408L6.34443 11.9576C6.5581 11.9855 6.77686 12 7 12C7.22314 12 7.4419 11.9855 7.65557 11.9576L7.91427 13.9408C7.61504 13.9799 7.30987 14 7 14C6.69013 14 6.38496 13.9799 6.08574 13.9408ZM9.67951 13.4688L8.91341 11.6214C9.31881 11.4533 9.69815 11.2328 10.0428 10.968L11.2615 12.5538C10.7798 12.9239 10.2486 13.2328 9.67951 13.4688ZM12.5538 11.2615L10.968 10.0428C11.2328 9.69815 11.4533 9.31881 11.6214 8.91341L13.4688 9.67951C13.2328 10.2486 12.924 10.7798 12.5538 11.2615Z" stroke="none"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<svg width="14" height="14" fill="none"><circle cx="7" cy="7" r="6" fill="none" stroke="#95a2b3" stroke-width="2" stroke-dasharray="3.14 0" stroke-dashoffset="-0.7"></circle><circle class="progress" cx="7" cy="7" r="3" fill="none" stroke="#95a2b3" rotate="20" stroke-width="6" stroke-dasharray="18.84955592153876 100" transform="rotate(-90 7 7)"></circle><path class="icon" stroke="none" d="M4.21967 4.21967C4.51256 3.92678 4.98743 3.92678 5.28032 4.21967L6.26516 5.2045L7.25 6.18934L9.21966 4.21967C9.51255 3.92678 9.98743 3.92678 10.2803 4.21967C10.5732 4.51256 10.5732 4.98744 10.2803 5.28033L8.31065 7.25L10.2803 9.21967C10.5732 9.51257 10.5732 9.98744 10.2803 10.2803C9.98743 10.5732 9.51255 10.5732 9.21966 10.2803L7.25 8.31066L5.28032 10.2803C4.98743 10.5732 4.51256 10.5732 4.21967 10.2803C3.92678 9.98744 3.92678 9.51257 4.21967 9.21967L5.2045 8.23484L6.18934 7.25L4.21967 5.28033C3.92678 4.98744 3.92678 4.51256 4.21967 4.21967Z"></path></svg>

After

Width:  |  Height:  |  Size: 954 B

View File

@@ -0,0 +1 @@
<svg width="14" height="14" viewBox="0 0 14 14" aria-label="Done" fill="#5e6ad2"><path fill-rule="evenodd" clip-rule="evenodd" d="M7 0C3.13401 0 0 3.13401 0 7C0 10.866 3.13401 14 7 14C10.866 14 14 10.866 14 7C14 3.13401 10.866 0 7 0ZM11.101 5.10104C11.433 4.76909 11.433 4.23091 11.101 3.89896C10.7691 3.56701 10.2309 3.56701 9.89896 3.89896L5.5 8.29792L4.10104 6.89896C3.7691 6.56701 3.2309 6.56701 2.89896 6.89896C2.56701 7.2309 2.56701 7.7691 2.89896 8.10104L4.89896 10.101C5.2309 10.433 5.7691 10.433 6.10104 10.101L11.101 5.10104Z"></path></svg>

After

Width:  |  Height:  |  Size: 551 B

View File

@@ -0,0 +1 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="6" stroke="#F2C94C" stroke-width="2" fill="none"></rect><path fill="#F2C94C" stroke="none" d="M 3.5,3.5 L3.5,0 A3.5,3.5 0 1,1 0, 3.5 z" transform="translate(3.5,3.5)"></path></svg>

After

Width:  |  Height:  |  Size: 286 B

View File

@@ -0,0 +1 @@
<svg width="14" height="14" fill="none"><circle cx="7" cy="7" r="3.5" fill="none" stroke="#fc7839" stroke-width="7" stroke-dasharray="2 0" stroke-dashoffset="3.2"></circle><circle class="progress" cx="6" cy="7" r="2" fill="none" stroke="#fc7840" rotate="20" stroke-width="4" stroke-dasharray="0 100" transform="rotate(-90 7 7)"></circle><path class="icon" stroke="none" d="M8.0126 7.98223V9.50781C8.0126 9.92901 8.52329 10.1548 8.85102 9.87854L11.8258 7.37066C12.0581 7.17486 12.0581 6.82507 11.8258 6.62927L8.85102 4.12139C8.52329 3.84509 8.0126 4.07092 8.0126 4.49212V6.01763H5.98739V4.49218C5.98739 4.07098 5.4767 3.84515 5.14897 4.12146L2.17419 6.62933C1.94194 6.82513 1.94194 7.17492 2.17419 7.37072L5.14897 9.8786C5.4767 10.1549 5.98739 9.92907 5.98739 9.50787V7.98223H8.0126Z"></path></svg>

After

Width:  |  Height:  |  Size: 798 B

View File

@@ -0,0 +1 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="6" stroke="#858699" stroke-width="2" fill="none"></rect><path fill="#858699" stroke="none" d="M 3.5,3.5 L3.5,0 A3.5,3.5 0 0,1 3.5, 0 z" transform="translate(3.5,3.5)"></path></svg>

After

Width:  |  Height:  |  Size: 286 B

View File

@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="1 1 14 14" fill="#fb773f"><path d="M2 4.74695C2 4.68722 2.01039 4.62899 2.02989 4.57451L2.11601 4.42269C2.15266 4.37819 2.19711 4.33975 2.24806 4.30966L3.16473 3.76824L3.92054 5.08013L3.5 5.32852V5.8313H2V4.74695Z"></path><path d="M4.8372 4.53871L4.0814 3.22682L5.91473 2.14398L6.67054 3.45588L4.8372 4.53871Z"></path><path d="M7.5872 2.91446L6.8314 1.60257L7.74806 1.06115C7.7997 1.03065 7.85539 1.01027 7.91244 1H8.08756C8.14461 1.01027 8.2003 1.03065 8.25194 1.06115L9.1686 1.60257L8.4128 2.91446L8 2.67065L7.5872 2.91446Z"></path><path d="M9.32946 3.45588L10.0853 2.14398L11.9186 3.22682L11.1628 4.53871L9.32946 3.45588Z"></path><path d="M12.0795 5.08013L12.8353 3.76824L13.7519 4.30966C13.8029 4.33975 13.8473 4.37819 13.884 4.42269L13.9701 4.57451C13.9896 4.62899 14 4.68722 14 4.74695V5.8313H12.5V5.32852L12.0795 5.08013Z"></path><path d="M12.5 6.91565H14V9.08435H12.5V6.91565Z"></path><path d="M12.5 10.1687H14V11.253C14 11.3128 13.9896 11.371 13.9701 11.4255L13.884 11.5773C13.8473 11.6218 13.8029 11.6602 13.7519 11.6903L12.8353 12.2318L12.0795 10.9199L12.5 10.6715V10.1687Z"></path><path d="M11.1628 11.4613L11.9186 12.7732L10.0853 13.856L9.32946 12.5441L11.1628 11.4613Z"></path><path d="M8.4128 13.0855L9.1686 14.3974L8.25194 14.9389C8.2003 14.9694 8.14461 14.9897 8.08756 15H7.91244C7.85539 14.9897 7.7997 14.9694 7.74806 14.9389L6.8314 14.3974L7.5872 13.0855L8 13.3294L8.4128 13.0855Z"></path><path d="M6.67054 12.5441L5.91473 13.856L4.0814 12.7732L4.8372 11.4613L6.67054 12.5441Z"></path><path d="M3.92054 10.9199L3.16473 12.2318L2.24806 11.6903C2.19711 11.6602 2.15266 11.6218 2.11601 11.5773L2.02989 11.4255C2.01039 11.371 2 11.3128 2 11.253V10.1687H3.5V10.6715L3.92054 10.9199Z"></path><path d="M3.5 9.08435H2V6.91565H3.5V9.08435Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="1 1 14 14" fill="#DCD8FE93"><path d="M5.96967 5.96967C6.26256 5.67678 6.73744 5.67678 7.03033 5.96967L8 6.93934L8.96967 5.96967C9.26256 5.67678 9.73744 5.67678 10.0303 5.96967C10.3232 6.26256 10.3232 6.73744 10.0303 7.03033L9.06066 8L10.0303 8.96967C10.3232 9.26256 10.3232 9.73744 10.0303 10.0303C9.73744 10.3232 9.26256 10.3232 8.96967 10.0303L8 9.06066L7.03033 10.0303C6.73744 10.3232 6.26256 10.3232 5.96967 10.0303C5.67678 9.73744 5.67678 9.26256 5.96967 8.96967L6.93934 8L5.96967 7.03033C5.67678 6.73744 5.67678 6.26256 5.96967 5.96967Z"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M8.75581 1.21148C8.28876 0.929507 7.71124 0.929507 7.24419 1.21148L2.74419 3.92829C2.28337 4.20651 2 4.71711 2 5.26927V10.7307C2 11.2829 2.28337 11.7935 2.74419 12.0717L7.24419 14.7885C7.71124 15.0705 8.28876 15.0705 8.75581 14.7885L13.2558 12.0717C13.7166 11.7935 14 11.2829 14 10.7307V5.26927C14 4.71711 13.7166 4.20651 13.2558 3.92829L8.75581 1.21148ZM12.5 5.26928L8 2.55246L3.5 5.26927L3.5 10.7307L8 13.4475L12.5 10.7307L12.5 5.26928Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="1 1 14 14" fill="#5e6ad2"><path fill-rule="evenodd" clip-rule="evenodd" d="M12.5 5.125L8 2.5L3.5 5.125L3.5 10.4019L8 13.0269L12.5 10.4019L12.5 5.125ZM8.75581 1.20433C8.28876 0.93189 7.71124 0.931889 7.24419 1.20433L2.74419 3.82933C2.28337 4.09815 2 4.5915 2 5.125V10.4019C2 10.9354 2.28337 11.4287 2.74419 11.6976L7.24419 14.3226C7.71124 14.595 8.28876 14.595 8.75581 14.3226L13.2558 11.6976C13.7166 11.4287 14 10.9354 14 10.4019V5.125C14 4.5915 13.7166 4.09815 13.2558 3.82933L8.75581 1.20433Z"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M10.7381 5.69424C11.0526 5.96381 11.089 6.43728 10.8194 6.75178L7.81944 10.2518C7.68349 10.4104 7.48754 10.5051 7.27878 10.5131C7.07003 10.5212 6.86739 10.4417 6.71967 10.294L5.21967 8.79402C4.92678 8.50112 4.92678 8.02625 5.21967 7.73336C5.51256 7.44046 5.98744 7.44046 6.28033 7.73336L7.20764 8.66066L9.68056 5.77559C9.95012 5.4611 10.4236 5.42468 10.7381 5.69424Z"></path></svg>

After

Width:  |  Height:  |  Size: 971 B

View File

@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="1 1 14 14" fill="#DCD8FE93"><path fill-rule="evenodd" clip-rule="evenodd" d="M8.75581 1.21148C8.28876 0.929507 7.71124 0.929507 7.24419 1.21148L2.74419 3.92829C2.28337 4.20651 2 4.71711 2 5.26927V10.7307C2 11.2829 2.28337 11.7935 2.74419 12.0717L7.24419 14.7885C7.71124 15.0705 8.28876 15.0705 8.75581 14.7885L13.2558 12.0717C13.7166 11.7935 14 11.2829 14 10.7307V5.26927C14 4.71711 13.7166 4.20651 13.2558 3.92829L8.75581 1.21148ZM12.5 5.26928L8 2.55246L3.5 5.26927L3.5 10.7307L8 13.4475L12.5 10.7307L12.5 5.26928Z"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 5.75C6.91421 5.75 7.25 6.08579 7.25 6.5V9.5C7.25 9.91421 6.91421 10.25 6.5 10.25C6.08579 10.25 5.75 9.91421 5.75 9.5V6.5C5.75 6.08579 6.08579 5.75 6.5 5.75Z"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M9.5 5.75C9.91421 5.75 10.25 6.08579 10.25 6.5V9.5C10.25 9.91421 9.91421 10.25 9.5 10.25C9.08579 10.25 8.75 9.91421 8.75 9.5V6.5C8.75 6.08579 9.08579 5.75 9.5 5.75Z"></path></svg>

After

Width:  |  Height:  |  Size: 1009 B

View File

@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="1 1 14 14" fill="#e2e2e2"><path fill-rule="evenodd" clip-rule="evenodd" d="M12.5 5.36133L8 2.73633L3.5 5.36133L3.5 10.6382L8 13.2632L12.5 10.6382L12.5 5.36133ZM8.75581 1.44066C8.28876 1.16822 7.71124 1.16822 7.24419 1.44066L2.74419 4.06566C2.28337 4.33448 2 4.82783 2 5.36133V10.6382C2 11.1717 2.28337 11.6651 2.74419 11.9339L7.24419 14.5589C7.71124 14.8313 8.28876 14.8313 8.75581 14.5589L13.2558 11.9339C13.7166 11.6651 14 11.1717 14 10.6382V5.36133C14 4.82783 13.7166 4.33448 13.2558 4.06566L8.75581 1.44066Z"></path></svg>

After

Width:  |  Height:  |  Size: 564 B

View File

@@ -0,0 +1 @@
<svg width="16" height="16" fill="#f2be00" viewBox="1 1 14 14"><path d="M8.3779 4.74233C8.14438 4.60607 7.85562 4.60607 7.6221 4.74233L5.37209 6.05513C5.14168 6.18957 5 6.4363 5 6.70311V9.34216C5 9.60897 5.14168 9.85573 5.37209 9.99016L7.6221 11.303C7.85562 11.4392 8.14438 11.4392 8.3779 11.303L10.6279 9.99016C10.8583 9.85573 11 9.60897 11 9.34216V6.70311C11 6.4363 10.8583 6.18957 10.6279 6.05513L8.3779 4.74233Z" mask="url(#hole-50)"></path><mask id="hole-50"><rect width="100%" height="100%" fill="white"></rect><circle r="4" cx="7.5" cy="8" fill="black" stroke="white" stroke-width="8" stroke-dasharray="calc(12.56) 25.12" transform="rotate(-90) translate(-16)"></circle></mask><path fill-rule="evenodd" clip-rule="evenodd" d="M7.24419 1.44066C7.71124 1.16822 8.28876 1.16822 8.75581 1.44066L13.2558 4.06566C13.7166 4.33448 14 4.82783 14 5.36133V10.6382C14 11.1717 13.7166 11.6651 13.2558 11.9339L8.75581 14.5589C8.28876 14.8313 7.71124 14.8313 7.24419 14.5589L2.74419 11.9339C2.28337 11.6651 2 11.1717 2 10.6382V5.36133C2 4.82783 2.28337 4.33448 2.74419 4.06566L7.24419 1.44066ZM8 2.73633L12.5 5.36133V10.6382L8 13.2632L3.5 10.6382V5.36133L8 2.73633Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 16 16" height="200px" width="200px" xmlns="http://www.w3.org/2000/svg">
<path d="M3.362 10.11c0 .926-.756 1.681-1.681 1.681S0 11.036 0 10.111C0 9.186.756 8.43 1.68 8.43h1.682v1.68zm.846 0c0-.924.756-1.68 1.681-1.68s1.681.756 1.681 1.68v4.21c0 .924-.756 1.68-1.68 1.68a1.685 1.685 0 0 1-1.682-1.68v-4.21zM5.89 3.362c-.926 0-1.682-.756-1.682-1.681S4.964 0 5.89 0s1.68.756 1.68 1.68v1.682H5.89zm0 .846c.924 0 1.68.756 1.68 1.681S6.814 7.57 5.89 7.57H1.68C.757 7.57 0 6.814 0 5.89c0-.926.756-1.682 1.68-1.682h4.21zm6.749 1.682c0-.926.755-1.682 1.68-1.682.925 0 1.681.756 1.681 1.681s-.756 1.681-1.68 1.681h-1.681V5.89zm-.848 0c0 .924-.755 1.68-1.68 1.68A1.685 1.685 0 0 1 8.43 5.89V1.68C8.43.757 9.186 0 10.11 0c.926 0 1.681.756 1.681 1.68v4.21zm-1.681 6.748c.926 0 1.682.756 1.682 1.681S11.036 16 10.11 16s-1.681-.756-1.681-1.68v-1.682h1.68zm0-.847c-.924 0-1.68-.755-1.68-1.68 0-.925.756-1.681 1.68-1.681h4.21c.924 0 1.68.756 1.68 1.68 0 .926-.756 1.681-1.68 1.681h-4.21z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,2 @@
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 16 16" height="200px" width="200px" xmlns="http://www.w3.org/2000/svg"><path d="M3.362 10.11c0 .926-.756 1.681-1.681 1.681S0 11.036 0 10.111C0 9.186.756 8.43 1.68 8.43h1.682v1.68zm.846 0c0-.924.756-1.68 1.681-1.68s1.681.756 1.681 1.68v4.21c0 .924-.756 1.68-1.68 1.68a1.685 1.685 0 0 1-1.682-1.68v-4.21zM5.89 3.362c-.926 0-1.682-.756-1.682-1.681S4.964 0 5.89 0s1.68.756 1.68 1.68v1.682H5.89zm0 .846c.924 0 1.68.756 1.68 1.681S6.814 7.57 5.89 7.57H1.68C.757 7.57 0 6.814 0 5.89c0-.926.756-1.682 1.68-1.682h4.21zm6.749 1.682c0-.926.755-1.682 1.68-1.682.925 0 1.681.756 1.681 1.681s-.756 1.681-1.68 1.681h-1.681V5.89zm-.848 0c0 .924-.755 1.68-1.68 1.68A1.685 1.685 0 0 1 8.43 5.89V1.68C8.43.757 9.186 0 10.11 0c.926 0 1.681.756 1.681 1.68v4.21zm-1.681 6.748c.926 0 1.682.756 1.682 1.681S11.036 16 10.11 16s-1.681-.756-1.681-1.68v-1.682h1.68zm0-.847c-.924 0-1.68-.755-1.68-1.68 0-.925.756-1.681 1.68-1.681h4.21c.924 0 1.68.756 1.68 1.68 0 .926-.756 1.681-1.68 1.681h-4.21z"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -43,6 +43,7 @@
},
"nodejs@latest": {
"last_modified": "2024-01-14T03:55:27Z",
"plugin_version": "0.0.2",
"resolved": "github:NixOS/nixpkgs/dd5621df6dcb90122b50da5ec31c411a0de3e538#nodejs_21",
"source": "devbox-search",
"version": "21.5.0",

View File

@@ -5,6 +5,10 @@ default:
build:
npm run build
# Format code
format:
npm run format
# Lint extension code
lint:
npm run lint
@@ -16,3 +20,7 @@ fix:
# Run extension in development mode
run:
npm run dev
# Publish a new version of the extension
publish:
npm run publish

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

4516
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,11 @@
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "universal-inbox",
"title": "Universal Inbox",
"description": "Manage Universal Inbox notifications from Raycast",
"description": "Manage your notifications in a single Universal Inbox",
"icon": "ui-logo-transparent.png",
"author": "david_rousselie",
"owner": "universal-inbox",
"categories": [
"Applications",
"Productivity"
],
"author": "dax42",
"version": "0.2.0",
"categories": ["Productivity"],
"license": "MIT",
"preferences": [
{
@@ -40,18 +37,25 @@
"dependencies": {
"@raycast/api": "^1.65.1",
"@raycast/utils": "^1.10.1",
"node-fetch": "^3.3.2"
"dayjs": "^1.11.10",
"node-fetch": "^3.3.2",
"ts-pattern": "^5.0.6"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.6",
"@types/node": "20.8.10",
"@types/react": "18.2.27",
"@types/node": "^20.8.10",
"@types/react": "^18.2.27",
"@typescript-eslint/parser": "^7.2.0",
"eslint": "^8.56.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
"prettier": "^3.0.3",
"prettier-plugin-sort-imports": "^1.8.3",
"typescript": "^5.3.3"
},
"scripts": {
"build": "ray build -e dist",
"format": "prettier --write --list-different --ignore-unknown src",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint",

View File

@@ -1,73 +0,0 @@
import { Action, ActionPanel, Icon } from "@raycast/api";
import { useMemo, ReactElement } from "react";
import { getNotificationHtmlUrl } from "./notification";
import { Notification } from "./types";
function deleteNotification(notification: Notification) {
console.log(`Deleting notification ${notification.id}`);
}
function unsubscribeFromNotification(notification: Notification) {
console.log(`Unsubcribing from notification ${notification.id}`);
}
function snoozeNotification(notification: Notification) {
console.log(`Snoozing notification ${notification.id}`);
}
function createTaskFromNotification(notification: Notification) {
console.log(`Creating task notification ${notification.id}`);
}
function linkNotificationToTask(notification: Notification) {
console.log(`Linking notification ${notification.id}`);
}
export function NotificationActions({
notification,
detailsTarget,
}: {
notification: Notification;
detailsTarget: ReactElement;
}) {
const notification_html_url = useMemo(() => {
return getNotificationHtmlUrl(notification);
}, [notification]);
return (
<ActionPanel>
<Action.Push title="Show Details" target={detailsTarget} />
<Action.OpenInBrowser url={notification_html_url} />
<Action
title="Delete Notification"
icon={Icon.Trash}
shortcut={{ modifiers: ["ctrl"], key: "d" }}
onAction={() => deleteNotification(notification)}
/>
<Action
title="Unsubscribe From Notification"
icon={Icon.BellDisabled}
shortcut={{ modifiers: ["ctrl"], key: "u" }}
onAction={() => unsubscribeFromNotification(notification)}
/>
<Action
title="Snooze"
icon={Icon.Clock}
shortcut={{ modifiers: ["ctrl"], key: "s" }}
onAction={() => snoozeNotification(notification)}
/>
<Action
title="Create Task"
icon={Icon.Calendar}
shortcut={{ modifiers: ["ctrl"], key: "d" }}
onAction={() => createTaskFromNotification(notification)}
/>
<Action
title="Link to Task"
icon={Icon.Link}
shortcut={{ modifiers: ["ctrl"], key: "d" }}
onAction={() => linkNotificationToTask(notification)}
/>
</ActionPanel>
);
}

View File

@@ -1,63 +0,0 @@
import { Action, ActionPanel, Icon } from "@raycast/api";
import { useMemo, ReactElement } from "react";
import { getNotificationHtmlUrl } from "./notification";
import { Notification } from "./types";
function deleteNotification(notification: Notification) {
console.log(`Deleting notification ${notification.id}`);
}
function unsubscribeFromNotification(notification: Notification) {
console.log(`Unsubcribing from notification ${notification.id}`);
}
function snoozeNotification(notification: Notification) {
console.log(`Snoozing notification ${notification.id}`);
}
function completeTask(notification: Notification) {
console.log(`Completing task ${notification.id}`);
}
export function NotificationTaskActions({
notification,
detailsTarget,
}: {
notification: Notification;
detailsTarget: ReactElement;
}) {
const notification_html_url = useMemo(() => {
return getNotificationHtmlUrl(notification);
}, [notification]);
return (
<ActionPanel>
<Action.Push title="Show Details" target={detailsTarget} />
<Action.OpenInBrowser url={notification_html_url} />
<Action
title="Delete Notification"
icon={Icon.Trash}
shortcut={{ modifiers: ["ctrl"], key: "d" }}
onAction={() => deleteNotification(notification)}
/>
<Action
title="Unsubscribe From Notification"
icon={Icon.BellDisabled}
shortcut={{ modifiers: ["ctrl"], key: "u" }}
onAction={() => unsubscribeFromNotification(notification)}
/>
<Action
title="Snooze"
icon={Icon.Clock}
shortcut={{ modifiers: ["ctrl"], key: "s" }}
onAction={() => snoozeNotification(notification)}
/>
<Action
title="Complete Task"
icon={Icon.Calendar}
shortcut={{ modifiers: ["ctrl"], key: "c" }}
onAction={() => completeTask(notification)}
/>
</ActionPanel>
);
}

View File

@@ -0,0 +1,153 @@
import { Action, useNavigation, ActionPanel, Form, Icon, getPreferenceValues, showToast, Toast } from "@raycast/api";
import { MutatePromise, useForm, FormValidation, useFetch } from "@raycast/utils";
import { Page, UniversalInboxPreferences } from "../types";
import { default as dayjs, extend } from "dayjs";
import { Notification } from "../notification";
import { TaskPriority } from "../task";
import { handleErrors } from "../api";
import utc from "dayjs/plugin/utc";
import { useState } from "react";
import fetch from "node-fetch";
extend(utc);
interface CreateTaskFromNotificationProps {
notification: Notification;
mutate: MutatePromise<Page<Notification> | undefined>;
}
interface TaskCreationFormValues {
title: string;
project: string;
dueAt: Date | null;
priority: string;
}
interface ProjectSummary {
name: string;
source_id: string;
}
interface TaskCreation {
title: string;
project: ProjectSummary;
due_at?: { type: "DateTimeWithTz"; content: string };
priority: TaskPriority;
}
export function CreateTaskFromNotification({ notification, mutate }: CreateTaskFromNotificationProps) {
const preferences = getPreferenceValues<UniversalInboxPreferences>();
const { pop } = useNavigation();
const [searchText, setSearchText] = useState("");
const { isLoading, data: projects } = useFetch<Array<ProjectSummary>>(
`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/tasks/projects/search?matches=${searchText}`,
{
keepPreviousData: true,
headers: {
Authorization: `Bearer ${preferences.apiKey}`,
},
},
);
const { handleSubmit, itemProps } = useForm<TaskCreationFormValues>({
initialValues: {
title: notification.title,
project: projects?.find((p) => p.name === "Inbox")?.source_id,
dueAt: new Date(),
priority: `${TaskPriority.P4 as number}`,
},
async onSubmit(values) {
const project = projects?.find((p) => p.source_id === values.project);
if (!project) {
throw new Error("Project not found");
}
const taskCreation: TaskCreation = {
title: values.title,
project: project,
due_at: values.dueAt ? { type: "DateTimeWithTz", content: dayjs(values.dueAt).utc().format() } : undefined,
priority: parseInt(values.priority) as TaskPriority,
};
await createTaskFromNotification(taskCreation, notification, mutate);
pop();
},
validation: {
title: FormValidation.Required,
project: FormValidation.Required,
priority: FormValidation.Required,
},
});
return (
<Form
navigationTitle="Create task from notification"
actions={
<ActionPanel>
<Action.SubmitForm title="Create Task" icon={Icon.Calendar} onSubmit={handleSubmit} />
</ActionPanel>
}
>
<Form.TextField title="Task title" placeholder="Enter task title" {...itemProps.title} />
<Form.Dropdown
title="Project"
placeholder="Search project..."
filtering={true}
throttle={true}
isLoading={isLoading}
onSearchTextChange={setSearchText}
{...itemProps.project}
>
<Form.Dropdown.Item value="" title="" key={0} />
{projects?.map((project) => {
return <Form.Dropdown.Item title={project.name} value={project.source_id} key={project.source_id} />;
})}
</Form.Dropdown>
<Form.DatePicker title="Due at" min={new Date()} type={Form.DatePicker.Type.Date} {...itemProps.dueAt} />
<Form.Dropdown title="Priority" {...itemProps.priority}>
<Form.Dropdown.Item title="Priority 1" value={`${TaskPriority.P1 as number}`} key={TaskPriority.P1} />
<Form.Dropdown.Item title="Priority 2" value={`${TaskPriority.P2 as number}`} key={TaskPriority.P2} />
<Form.Dropdown.Item title="Priority 3" value={`${TaskPriority.P3 as number}`} key={TaskPriority.P3} />
<Form.Dropdown.Item title="Priority 4" value={`${TaskPriority.P4 as number}`} key={TaskPriority.P4} />
</Form.Dropdown>
</Form>
);
}
async function createTaskFromNotification(
taskCreation: TaskCreation,
notification: Notification,
mutate: MutatePromise<Page<Notification> | undefined>,
) {
const preferences = getPreferenceValues<UniversalInboxPreferences>();
const toast = await showToast({ style: Toast.Style.Animated, title: "Creating task from notification" });
try {
await mutate(
handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/notifications/${notification.id}/task`, {
method: "POST",
body: JSON.stringify(taskCreation),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${preferences.apiKey}`,
},
}),
),
{
optimisticUpdate(page) {
if (page) {
page.content = page.content.filter((n) => n.id !== notification.id);
}
return page;
},
},
);
toast.style = Toast.Style.Success;
toast.title = "Task successfully created";
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Failed to create task from notification";
toast.message = (error as Error).message;
throw error;
}
}

View File

@@ -0,0 +1,108 @@
import { Action, ActionPanel, useNavigation, Form, Icon, getPreferenceValues, showToast, Toast } from "@raycast/api";
import { MutatePromise, useForm, FormValidation, useFetch } from "@raycast/utils";
import { Page, UniversalInboxPreferences } from "../types";
import { Notification } from "../notification";
import { Task, TaskStatus } from "../task";
import { handleErrors } from "../api";
import { useState } from "react";
import fetch from "node-fetch";
interface LinkNotificationToTaskProps {
notification: Notification;
mutate: MutatePromise<Page<Notification> | undefined>;
}
interface TaskLinkFormValues {
taskId: string;
}
export function LinkNotificationToTask({ notification, mutate }: LinkNotificationToTaskProps) {
const preferences = getPreferenceValues<UniversalInboxPreferences>();
const { pop } = useNavigation();
const [searchText, setSearchText] = useState("");
const { isLoading, data: tasks } = useFetch<Array<Task>>(
`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/tasks/search?matches=${searchText}`,
{
keepPreviousData: true,
headers: {
Authorization: `Bearer ${preferences.apiKey}`,
},
},
);
const { handleSubmit, itemProps } = useForm<TaskLinkFormValues>({
async onSubmit(values) {
await linkNotificationToTask(notification, values.taskId, mutate);
pop();
},
validation: {
taskId: FormValidation.Required,
},
});
return (
<Form
navigationTitle="Link notification with task"
actions={
<ActionPanel>
<Action.SubmitForm title="Link to Task" icon={Icon.Link} onSubmit={handleSubmit} />
</ActionPanel>
}
>
<Form.Dropdown
title="Task"
placeholder="Search task..."
filtering={true}
throttle={true}
isLoading={isLoading}
onSearchTextChange={setSearchText}
{...itemProps.taskId}
>
<Form.Dropdown.Item value="" title="" key={0} />
{tasks?.map((task) => {
return <Form.Dropdown.Item title={task.title} value={task.id} key={task.id} />;
})}
</Form.Dropdown>
</Form>
);
}
async function linkNotificationToTask(
notification: Notification,
taskId: string,
mutate: MutatePromise<Page<Notification> | undefined>,
) {
const preferences = getPreferenceValues<UniversalInboxPreferences>();
const toast = await showToast({ style: Toast.Style.Animated, title: "Linking notification to task" });
try {
await mutate(
handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/notifications/${notification.id}`, {
method: "PATCH",
body: JSON.stringify({ status: TaskStatus.Deleted, task_id: taskId }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${preferences.apiKey}`,
},
}),
),
{
optimisticUpdate(page) {
if (page) {
page.content = page.content.filter((n) => n.id !== notification.id);
}
return page;
},
},
);
toast.style = Toast.Style.Success;
toast.title = "Notification successfully linked to task";
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Failed to link notification to task";
toast.message = (error as Error).message;
throw error;
}
}

View File

@@ -0,0 +1,208 @@
import { Notification, NotificationStatus, getNotificationHtmlUrl, isNotificationBuiltFromTask } from "../notification";
import { Action, ActionPanel, Icon, getPreferenceValues, showToast, Toast } from "@raycast/api";
import { CreateTaskFromNotification } from "./CreateTaskFromNotification";
import { LinkNotificationToTask } from "./LinkNotificationToTask";
import { Page, UniversalInboxPreferences } from "../types";
import { default as dayjs, extend } from "dayjs";
import { MutatePromise } from "@raycast/utils";
import { useMemo, ReactElement } from "react";
import { handleErrors } from "../api";
import { TaskStatus } from "../task";
import utc from "dayjs/plugin/utc";
import fetch from "node-fetch";
extend(utc);
interface NotificationActionsProps {
notification: Notification;
detailsTarget: ReactElement;
mutate: MutatePromise<Page<Notification> | undefined>;
}
export function NotificationActions({ notification, detailsTarget, mutate }: NotificationActionsProps) {
const notificationHtmlUrl = useMemo(() => {
return getNotificationHtmlUrl(notification);
}, [notification]);
return (
<ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} />
<Action.Push title="Show Details" icon={Icon.AppWindowSidebarRight} target={detailsTarget} />
<Action
title="Delete Notification"
icon={Icon.Trash}
shortcut={{ modifiers: ["ctrl"], key: "d" }}
onAction={() => deleteNotification(notification, mutate)}
/>
<Action
title="Unsubscribe from Notification"
icon={Icon.BellDisabled}
shortcut={{ modifiers: ["ctrl"], key: "u" }}
onAction={() => unsubscribeFromNotification(notification, mutate)}
/>
<Action
title="Snooze"
icon={Icon.Clock}
shortcut={{ modifiers: ["ctrl"], key: "s" }}
onAction={() => snoozeNotification(notification, mutate)}
/>
<Action.Push
title="Create Task…"
icon={Icon.Calendar}
shortcut={{ modifiers: ["ctrl"], key: "t" }}
target={<CreateTaskFromNotification notification={notification} mutate={mutate} />}
/>
<Action.Push
title="Link to Task…"
icon={Icon.Link}
shortcut={{ modifiers: ["ctrl"], key: "l" }}
target={<LinkNotificationToTask notification={notification} mutate={mutate} />}
/>
</ActionPanel>
);
}
export async function deleteNotification(
notification: Notification,
mutate: MutatePromise<Page<Notification> | undefined>,
) {
const preferences = getPreferenceValues<UniversalInboxPreferences>();
const toast = await showToast({ style: Toast.Style.Animated, title: "Deleting notification" });
try {
if (isNotificationBuiltFromTask(notification) && notification.task) {
await mutate(
handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/tasks/${notification.task.id}`, {
method: "PATCH",
body: JSON.stringify({ status: TaskStatus.Deleted }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${preferences.apiKey}`,
},
}),
),
{
optimisticUpdate(page) {
if (page) {
page.content = page.content.filter((n) => n.id !== notification.id);
}
return page;
},
},
);
} else {
await mutate(
handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/notifications/${notification.id}`, {
method: "PATCH",
body: JSON.stringify({ status: NotificationStatus.Deleted }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${preferences.apiKey}`,
},
}),
),
{
optimisticUpdate(page) {
if (page) {
page.content = page.content.filter((n) => n.id !== notification.id);
}
return page;
},
},
);
}
toast.style = Toast.Style.Success;
toast.title = "Notification successfully deleted";
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Failed to delete notification";
toast.message = (error as Error).message;
throw error;
}
}
export async function unsubscribeFromNotification(
notification: Notification,
mutate: MutatePromise<Page<Notification> | undefined>,
) {
const preferences = getPreferenceValues<UniversalInboxPreferences>();
const toast = await showToast({ style: Toast.Style.Animated, title: "Unsubscribing from notification" });
try {
await mutate(
handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/notifications/${notification.id}`, {
method: "PATCH",
body: JSON.stringify({ status: NotificationStatus.Unsubscribed }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${preferences.apiKey}`,
},
}),
),
{
optimisticUpdate(page) {
if (page) {
page.content = page.content.filter((n) => n.id !== notification.id);
}
return page;
},
},
);
toast.style = Toast.Style.Success;
toast.title = "Notification successfully unsubscribed";
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Failed to unsubscribe from notification";
toast.message = (error as Error).message;
throw error;
}
}
export async function snoozeNotification(
notification: Notification,
mutate: MutatePromise<Page<Notification> | undefined>,
) {
const preferences = getPreferenceValues<UniversalInboxPreferences>();
const toast = await showToast({ style: Toast.Style.Animated, title: "Snoozing notification" });
try {
const snoozeTime = computeSnoozedUntil(new Date(), 1, 6);
await mutate(
handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/notifications/${notification.id}`, {
method: "PATCH",
body: JSON.stringify({ snoozed_until: snoozeTime }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${preferences.apiKey}`,
},
}),
),
{
optimisticUpdate(page) {
if (page) {
page.content = page.content.filter((n) => n.id !== notification.id);
}
return page;
},
},
);
toast.style = Toast.Style.Success;
toast.title = "Notification successfully snoozed";
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Failed to snooze notification";
toast.message = (error as Error).message;
throw error;
}
}
function computeSnoozedUntil(fromDate: Date, daysOffset: number, resetHour: number): Date {
const result = dayjs(fromDate)
.utc()
.add(fromDate.getHours() < resetHour ? daysOffset - 1 : daysOffset, "day");
return result.hour(resetHour).minute(0).second(0).millisecond(0).toDate();
}

View File

@@ -0,0 +1,98 @@
import { deleteNotification, snoozeNotification, unsubscribeFromNotification } from "./NotificationActions";
import { Notification, getNotificationHtmlUrl, isNotificationBuiltFromTask } from "../notification";
import { Action, ActionPanel, Icon, Toast, getPreferenceValues, showToast } from "@raycast/api";
import { Page, UniversalInboxPreferences } from "../types";
import { MutatePromise } from "@raycast/utils";
import { useMemo, ReactElement } from "react";
import { PlanTask } from "./PlanTask";
import { handleErrors } from "../api";
import { TaskStatus } from "../task";
import fetch from "node-fetch";
interface NotificationTaskActionsProps {
notification: Notification;
detailsTarget: ReactElement;
mutate: MutatePromise<Page<Notification> | undefined>;
}
export function NotificationTaskActions({ notification, detailsTarget, mutate }: NotificationTaskActionsProps) {
const notificationHtmlUrl = useMemo(() => {
return getNotificationHtmlUrl(notification);
}, [notification]);
return (
<ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} />
<Action.Push title="Show Details" icon={Icon.AppWindowSidebarRight} target={detailsTarget} />
<Action
title="Delete Notification"
icon={Icon.Trash}
shortcut={{ modifiers: ["ctrl"], key: "d" }}
onAction={() => deleteNotification(notification, mutate)}
/>
<Action
title="Unsubscribe from Notification"
icon={Icon.BellDisabled}
shortcut={{ modifiers: ["ctrl"], key: "u" }}
onAction={() => unsubscribeFromNotification(notification, mutate)}
/>
<Action
title="Snooze"
icon={Icon.Clock}
shortcut={{ modifiers: ["ctrl"], key: "s" }}
onAction={() => snoozeNotification(notification, mutate)}
/>
<Action
title="Complete Task"
icon={Icon.CheckCircle}
shortcut={{ modifiers: ["ctrl"], key: "c" }}
onAction={() => completeTask(notification, mutate)}
/>
<Action.Push
title="Plan Task…"
icon={Icon.Calendar}
shortcut={{ modifiers: ["ctrl"], key: "t" }}
target={<PlanTask notification={notification} mutate={mutate} />}
/>
</ActionPanel>
);
}
async function completeTask(notification: Notification, mutate: MutatePromise<Page<Notification> | undefined>) {
if (!isNotificationBuiltFromTask(notification) || !notification.task) {
return;
}
const preferences = getPreferenceValues<UniversalInboxPreferences>();
const toast = await showToast({ style: Toast.Style.Animated, title: "Marking task as Done" });
try {
await mutate(
handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/tasks/${notification.task.id}`, {
method: "PATCH",
body: JSON.stringify({ status: TaskStatus.Done }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${preferences.apiKey}`,
},
}),
),
{
optimisticUpdate(page) {
if (page) {
page.content = page.content.filter((n) => n.id !== notification.id);
}
return page;
},
},
);
toast.style = Toast.Style.Success;
toast.title = "Task successfully marked as Done";
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Failed to mark task as Done";
toast.message = (error as Error).message;
throw error;
}
}

156
src/action/PlanTask.tsx Normal file
View File

@@ -0,0 +1,156 @@
import { Action, useNavigation, ActionPanel, Form, Icon, getPreferenceValues, showToast, Toast } from "@raycast/api";
import { MutatePromise, useForm, FormValidation, useFetch } from "@raycast/utils";
import { Notification, isNotificationBuiltFromTask } from "../notification";
import { Page, UniversalInboxPreferences } from "../types";
import { default as dayjs, extend } from "dayjs";
import { TaskPriority } from "../task";
import { handleErrors } from "../api";
import utc from "dayjs/plugin/utc";
import { useState } from "react";
import fetch from "node-fetch";
extend(utc);
interface PlanTaskProps {
notification: Notification;
mutate: MutatePromise<Page<Notification> | undefined>;
}
interface TaskPlanningFormValues {
project: string;
dueAt: Date | null;
priority: string;
}
interface ProjectSummary {
name: string;
source_id: string;
}
interface TaskPlanning {
project: ProjectSummary;
due_at?: { type: "DateTimeWithTz"; content: string };
priority: TaskPriority;
}
export function PlanTask({ notification, mutate }: PlanTaskProps) {
const preferences = getPreferenceValues<UniversalInboxPreferences>();
const { pop } = useNavigation();
const [searchText, setSearchText] = useState("");
const { isLoading, data: projects } = useFetch<Array<ProjectSummary>>(
`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/tasks/projects/search?matches=${searchText}`,
{
keepPreviousData: true,
headers: {
Authorization: `Bearer ${preferences.apiKey}`,
},
},
);
const { handleSubmit, itemProps } = useForm<TaskPlanningFormValues>({
initialValues: {
dueAt: new Date(),
project: projects?.find((p) => p.name === "Inbox")?.source_id,
priority: `${TaskPriority.P4 as number}`,
},
async onSubmit(values) {
const project = projects?.find((p) => p.source_id === values.project);
if (!project) {
throw new Error("Project not found");
}
const taskPlanning: TaskPlanning = {
project: project,
due_at: values.dueAt ? { type: "DateTimeWithTz", content: dayjs(values.dueAt).utc().format() } : undefined,
priority: parseInt(values.priority) as TaskPriority,
};
await planTask(taskPlanning, notification, mutate);
pop();
},
validation: {
project: FormValidation.Required,
priority: FormValidation.Required,
},
});
return (
<Form
navigationTitle="Plan task"
actions={
<ActionPanel>
<Action.SubmitForm title="Plan Task" icon={Icon.Calendar} onSubmit={handleSubmit} />
</ActionPanel>
}
>
<Form.Description title="Task title" text={notification.title} />
<Form.Dropdown
title="Project"
placeholder="Search project..."
filtering={true}
throttle={true}
isLoading={isLoading}
onSearchTextChange={setSearchText}
{...itemProps.project}
>
<Form.Dropdown.Item value="" title="" key={0} />
{projects?.map((project) => {
return <Form.Dropdown.Item title={project.name} value={project.source_id} key={project.source_id} />;
})}
</Form.Dropdown>
<Form.DatePicker title="Due at" min={new Date()} type={Form.DatePicker.Type.Date} {...itemProps.dueAt} />
<Form.Dropdown title="Priority" {...itemProps.priority}>
<Form.Dropdown.Item title="Priority 1" value={`${TaskPriority.P1 as number}`} key={TaskPriority.P1} />
<Form.Dropdown.Item title="Priority 2" value={`${TaskPriority.P2 as number}`} key={TaskPriority.P2} />
<Form.Dropdown.Item title="Priority 3" value={`${TaskPriority.P3 as number}`} key={TaskPriority.P3} />
<Form.Dropdown.Item title="Priority 4" value={`${TaskPriority.P4 as number}`} key={TaskPriority.P4} />
</Form.Dropdown>
</Form>
);
}
async function planTask(
taskPlanning: TaskPlanning,
notification: Notification,
mutate: MutatePromise<Page<Notification> | undefined>,
) {
if (!isNotificationBuiltFromTask(notification) || !notification.task) {
return;
}
const preferences = getPreferenceValues<UniversalInboxPreferences>();
const toast = await showToast({ style: Toast.Style.Animated, title: "Planning task" });
try {
await mutate(
handleErrors(
fetch(`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/tasks/${notification.task.id}`, {
method: "PATCH",
body: JSON.stringify({
project: taskPlanning.project.name,
due_at: taskPlanning.due_at,
priority: taskPlanning.priority,
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${preferences.apiKey}`,
},
}),
),
{
optimisticUpdate(page) {
if (page) {
page.content = page.content.filter((n) => n.id !== notification.id);
}
return page;
},
},
);
toast.style = Toast.Style.Success;
toast.title = "Task successfully planned";
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Failed to plan task";
toast.message = (error as Error).message;
throw error;
}
}

20
src/api.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Response } from "node-fetch";
import { match } from "ts-pattern";
interface ResponseError {
message: string;
}
export async function handleErrors(response: Promise<Response>) {
return match(await response)
.with({ status: 400 }, async (r) => {
throw new Error(((await r.json()) as ResponseError).message);
})
.with({ status: 401 }, async (r) => {
throw new Error(((await r.json()) as ResponseError).message);
})
.with({ status: 500 }, async (r) => {
throw new Error(((await r.json()) as ResponseError).message);
})
.otherwise((resp) => resp);
}

View File

@@ -1,35 +1,41 @@
import { Action, ActionPanel, Detail, List, getPreferenceValues, openExtensionPreferences } from "@raycast/api";
import { Action, ActionPanel, Detail, Icon, List, getPreferenceValues, openExtensionPreferences } from "@raycast/api";
import { GoogleMailNotificationListItem } from "./integrations/google-mail/listitem/GoogleMailNotificationListItem";
import { TodoistNotificationListItem } from "./integrations/todoist/listitem/TodoistNotificationListItem";
import { GithubNotificationListItem } from "./integrations/github/listitem/GithubNotificationListItem";
import { LinearNotificationListItem } from "./integrations/linear/listitem/LinearNotificationListItem";
import { SlackNotificationListItem } from "./integrations/slack/listitem/SlackNotificationListItem";
import { Notification, NotificationListItemProps, NotificationSourceKind } from "./notification";
import { NotificationActions } from "./action/NotificationActions";
import { Page, UniversalInboxPreferences } from "./types";
import { useFetch } from "@raycast/utils";
import { NotificationActions } from "./NotificationActions";
import { GithubNotificationListItem } from "./integrations/github/GithubNotificationListItem";
import { GoogleMailNotificationListItem } from "./integrations/google-mail/GoogleMailNotificationListItem";
import { LinearNotificationListItem } from "./integrations/linear/LinearNotificationListItem";
import { TodoistNotificationListItem } from "./integrations/todoist/TodoistNotificationListItem";
import { Notification, NotificationListItemProps, Page, UniversalInboxPreferences } from "./types";
import { useState } from "react";
export default function Command() {
const preferences = getPreferenceValues<UniversalInboxPreferences>();
if (
preferences.apiKey === undefined ||
preferences.apiKey === ""
/* preferences.universalInboxBaseUrl === undefined ||
* preferences.universalInboxBaseUrl === "" */
preferences.apiKey === "" ||
preferences.universalInboxBaseUrl === undefined ||
preferences.universalInboxBaseUrl === ""
) {
return (
<Detail
markdown={"API key incorrect. Please update it in extension preferences and try again."}
actions={
<ActionPanel>
<Action title="Open Extension Preferences" onAction={openExtensionPreferences} />
<Action title="Open Extension Preferences" icon={Icon.Gear} onAction={openExtensionPreferences} />
</ActionPanel>
}
/>
);
}
const { isLoading, data } = useFetch<Page<Notification>>(
`${preferences.universalInboxBaseUrl}/api/notifications?status=Unread,Read&with_tasks=true`,
const [notificationKind, setNotificationKind] = useState("");
const { isLoading, data, mutate } = useFetch<Page<Notification>>(
`${preferences.universalInboxBaseUrl.replace(/\/$/, "")}/api/notifications?status=Unread,Read&with_tasks=true${
notificationKind ? "&notification_kind=" + notificationKind : ""
}`,
{
headers: {
Authorization: `Bearer ${preferences.apiKey}`,
@@ -38,38 +44,78 @@ export default function Command() {
);
return (
<List isLoading={isLoading}>
{data?.content.map((notification: Notification) => {
return <NotificationListItem key={notification.id} notification={notification} />;
})}
<List
isLoading={isLoading}
searchBarPlaceholder="Filter notifications..."
searchBarAccessory={
<NotificationKindDropdown value={notificationKind} onNotificationKindChange={setNotificationKind} />
}
>
{data?.content.length === 0 ? (
<List.EmptyView
icon={{ source: "ui-logo-transparent.png" }}
title="Congrats! You have reach zero inbox 🎉"
description="You don't have any new notifications."
/>
) : (
data?.content.map((notification: Notification) => {
return <NotificationListItem key={notification.id} notification={notification} mutate={mutate} />;
})
)}
</List>
);
}
function NotificationListItem({ notification }: NotificationListItemProps) {
switch (notification.metadata.type) {
case "Github":
return <GithubNotificationListItem notification={notification} />;
case "Linear":
return <LinearNotificationListItem notification={notification} />;
case "GoogleMail":
return <GoogleMailNotificationListItem notification={notification} />;
case "Todoist":
return <TodoistNotificationListItem notification={notification} />;
function NotificationListItem({ notification, mutate }: NotificationListItemProps) {
switch (notification.kind) {
case NotificationSourceKind.Github:
return <GithubNotificationListItem notification={notification} mutate={mutate} />;
case NotificationSourceKind.Linear:
return <LinearNotificationListItem notification={notification} mutate={mutate} />;
case NotificationSourceKind.GoogleMail:
return <GoogleMailNotificationListItem notification={notification} mutate={mutate} />;
case NotificationSourceKind.Slack:
return <SlackNotificationListItem notification={notification} mutate={mutate} />;
case NotificationSourceKind.Todoist:
return <TodoistNotificationListItem notification={notification} mutate={mutate} />;
default:
return <DefaultNotificationListItem notification={notification} />;
return <DefaultNotificationListItem notification={notification} mutate={mutate} />;
}
}
function DefaultNotificationListItem({ notification }: NotificationListItemProps) {
function DefaultNotificationListItem({ notification, mutate }: NotificationListItemProps) {
return (
<List.Item
key={notification.id}
title={notification.title}
subtitle={`#${notification.source_id}`}
subtitle={`#${notification.source_item.source_id}`}
actions={
<NotificationActions notification={notification} detailsTarget={<Detail markdown="# To be implemented 👋" />} />
<NotificationActions
notification={notification}
detailsTarget={<Detail markdown="# To be implemented 👋" />}
mutate={mutate}
/>
}
/>
);
}
interface NotificationKindDropdownProps {
value: string;
onNotificationKindChange: (newValue: string) => void;
}
function NotificationKindDropdown({ value, onNotificationKindChange }: NotificationKindDropdownProps) {
return (
<List.Dropdown tooltip="Select Notification Kind" value={value} onChange={onNotificationKindChange}>
<List.Dropdown.Section title="Notification kind">
<List.Dropdown.Item key="0" title="" value="" />
<List.Dropdown.Item key="Github" title="Github" value="Github" />
<List.Dropdown.Item key="Linear" title="Linear" value="Linear" />
<List.Dropdown.Item key="GoogleMail" title="Google Mail" value="GoogleMail" />
<List.Dropdown.Item key="Slack" title="Slack" value="Slack" />
<List.Dropdown.Item key="Todoist" title="Todoist" value="Todoist" />
</List.Dropdown.Section>
</List.Dropdown>
);
}

View File

@@ -1,35 +0,0 @@
import { environment } from "@raycast/api";
import { useMemo } from "react";
import { NotificationListItemProps } from "../../types";
import { GithubDiscussionNotificationListItem } from "./listitem/GithubDiscussionNotificationListItem";
import { GithubPullRequestNotificationListItem } from "./listitem/GithubPullRequestNotificationListItem";
export function GithubNotificationListItem({ notification }: NotificationListItemProps) {
const icon = useMemo(() => {
if (environment.appearance === "dark") {
return "github-logo-light.svg";
}
return "github-logo-dark.svg";
}, [environment]);
switch (notification.details?.type) {
case "GithubPullRequest":
return (
<GithubPullRequestNotificationListItem
icon={icon}
notification={notification}
githubPullRequest={notification.details.content}
/>
);
case "GithubDiscussion":
return (
<GithubDiscussionNotificationListItem
icon={icon}
notification={notification}
githubDiscussion={notification.details.content}
/>
);
default:
return null;
}
}

View File

@@ -1,10 +1,13 @@
import { Icon, Image, List } from "@raycast/api";
import { GithubActor, getGithubActorName } from "./types";
import { Icon, Image, List } from "@raycast/api";
import { getAvatarIcon } from "@raycast/utils";
export function getGithubActorAccessory(actor?: GithubActor): List.Item.Accessory {
if (actor) {
return {
icon: actor.content.avatar_url ? { source: actor.content.avatar_url, mask: Image.Mask.Circle } : Icon.Person,
icon: actor.content.avatar_url
? { source: actor.content.avatar_url, mask: Image.Mask.Circle }
: getAvatarIcon(getGithubActorName(actor)),
tooltip: getGithubActorName(actor),
};
}

View File

@@ -1,20 +1,22 @@
import { Color, Icon, List } from "@raycast/api";
import { NotificationActions } from "../../../NotificationActions";
import { Notification } from "../../../types";
import { getGithubActorAccessory } from "../misc";
import { GithubDiscussion, GithubDiscussionStateReason } from "../types";
import { GithubDiscussionPreview } from "../preview/GithubDiscussionPreview";
import { NotificationActions } from "../../../action/NotificationActions";
import { GithubDiscussion, GithubDiscussionStateReason } from "../types";
import { getGithubActorAccessory } from "../accessories";
import { Notification } from "../../../notification";
import { Color, Icon, List } from "@raycast/api";
import { MutatePromise } from "@raycast/utils";
import { Page } from "../../../types";
interface GithubDiscussionNotificationListItemProps {
icon: string;
notification: Notification;
githubDiscussion: GithubDiscussion;
mutate: MutatePromise<Page<Notification> | undefined>;
}
export function GithubDiscussionNotificationListItem({
icon,
notification,
githubDiscussion,
mutate,
}: GithubDiscussionNotificationListItemProps) {
const subtitle = `${githubDiscussion.repository.name_with_owner}`;
@@ -41,13 +43,14 @@ export function GithubDiscussionNotificationListItem({
<List.Item
key={notification.id}
title={notification.title}
icon={icon}
icon={{ source: { light: "github-logo-dark.svg", dark: "github-logo-light.svg" } }}
subtitle={subtitle}
accessories={accessories}
actions={
<NotificationActions
notification={notification}
detailsTarget={<GithubDiscussionPreview notification={notification} githubDiscussion={githubDiscussion} />}
mutate={mutate}
/>
}
/>

View File

@@ -0,0 +1,28 @@
import { GithubPullRequestNotificationListItem } from "./GithubPullRequestNotificationListItem";
import { GithubDiscussionNotificationListItem } from "./GithubDiscussionNotificationListItem";
import { NotificationListItemProps } from "../../../notification";
export function GithubNotificationListItem({ notification, mutate }: NotificationListItemProps) {
if (notification.source_item.data.type !== "GithubNotification") return null;
switch (notification.source_item.data.content.item?.type) {
case "GithubPullRequest":
return (
<GithubPullRequestNotificationListItem
notification={notification}
githubPullRequest={notification.source_item.data.content.item.content}
mutate={mutate}
/>
);
case "GithubDiscussion":
return (
<GithubDiscussionNotificationListItem
notification={notification}
githubDiscussion={notification.source_item.data.content.item.content}
mutate={mutate}
/>
);
default:
return null;
}
}

View File

@@ -1,7 +1,3 @@
import { Color, Icon, List } from "@raycast/api";
import { NotificationActions } from "../../../NotificationActions";
import { Notification } from "../../../types";
import { GithubPullRequestPreview } from "../preview/GithubPullRequestPreview";
import {
GithubPullRequestState,
GithubPullRequestReviewDecision,
@@ -11,18 +7,24 @@ import {
GithubCheckStatusState,
GithubCheckSuite,
} from "../types";
import { getGithubActorAccessory } from "../misc";
import { GithubPullRequestPreview } from "../preview/GithubPullRequestPreview";
import { NotificationActions } from "../../../action/NotificationActions";
import { getGithubActorAccessory } from "../accessories";
import { Notification } from "../../../notification";
import { Color, Icon, List } from "@raycast/api";
import { MutatePromise } from "@raycast/utils";
import { Page } from "../../../types";
interface GithubPullRequestNotificationListItemProps {
icon: string;
notification: Notification;
githubPullRequest: GithubPullRequest;
mutate: MutatePromise<Page<Notification> | undefined>;
}
export function GithubPullRequestNotificationListItem({
icon,
notification,
githubPullRequest,
mutate,
}: GithubPullRequestNotificationListItemProps) {
const subtitle = `${githubPullRequest.head_repository?.name_with_owner} #${githubPullRequest.number}`;
@@ -60,13 +62,14 @@ export function GithubPullRequestNotificationListItem({
<List.Item
key={notification.id}
title={notification.title}
icon={icon}
icon={{ source: { light: "github-logo-dark.svg", dark: "github-logo-light.svg" } }}
accessories={accessories}
subtitle={subtitle}
actions={
<NotificationActions
notification={notification}
detailsTarget={<GithubPullRequestPreview notification={notification} githubPullRequest={githubPullRequest} />}
mutate={mutate}
/>
}
/>
@@ -82,7 +85,7 @@ function getGithubPullRequestChecksAccessory(latestCommit: GithubCommitChecks):
case GithubCheckStatusState.Pending:
return { icon: Icon.Pause, tooltip: "Pending" };
case GithubCheckStatusState.InProgress:
return { icon: Icon.Pause, tooltip: "In progress" }; // TODO Spinner
return { icon: Icon.CircleProgress, tooltip: "In progress" };
case GithubCheckStatusState.Completed:
switch (progress.conclusion()) {
case GithubCheckConclusionState.Success:

View File

@@ -1,8 +1,7 @@
import { getNotificationHtmlUrl, Notification } from "../../../notification";
import { Detail, ActionPanel, Action } from "@raycast/api";
import { Notification } from "../../../types";
import { GithubDiscussion } from "../types";
import { useMemo } from "react";
import { getNotificationHtmlUrl } from "../../../notification";
interface GithubDiscussionPreviewProps {
notification: Notification;
@@ -10,7 +9,7 @@ interface GithubDiscussionPreviewProps {
}
export function GithubDiscussionPreview({ notification, githubDiscussion }: GithubDiscussionPreviewProps) {
const notification_html_url = useMemo(() => {
const notificationHtmlUrl = useMemo(() => {
return getNotificationHtmlUrl(notification);
}, [notification]);
@@ -19,7 +18,7 @@ export function GithubDiscussionPreview({ notification, githubDiscussion }: Gith
markdown={`# ${githubDiscussion.title}`}
actions={
<ActionPanel>
<Action.OpenInBrowser url={notification_html_url} />
<Action.OpenInBrowser url={notificationHtmlUrl} />
</ActionPanel>
}
/>

View File

@@ -1,8 +1,7 @@
import { getNotificationHtmlUrl, Notification } from "../../../notification";
import { Detail, ActionPanel, Action } from "@raycast/api";
import { Notification } from "../../../types";
import { GithubPullRequest } from "../types";
import { useMemo } from "react";
import { getNotificationHtmlUrl } from "../../../notification";
interface GithubPullRequestPreviewProps {
notification: Notification;
@@ -10,7 +9,7 @@ interface GithubPullRequestPreviewProps {
}
export function GithubPullRequestPreview({ notification, githubPullRequest }: GithubPullRequestPreviewProps) {
const notification_html_url = useMemo(() => {
const notificationHtmlUrl = useMemo(() => {
return getNotificationHtmlUrl(notification);
}, [notification]);
@@ -19,7 +18,7 @@ export function GithubPullRequestPreview({ notification, githubPullRequest }: Gi
markdown={`# ${githubPullRequest.title}`}
actions={
<ActionPanel>
<Action.OpenInBrowser url={notification_html_url} />
<Action.OpenInBrowser url={notificationHtmlUrl} />
</ActionPanel>
}
/>

View File

@@ -1,3 +1,60 @@
import { match, P } from "ts-pattern";
export interface GithubNotification {
id: string;
repository: GithubRepositorySummary;
subject: GithubNotificationSubject;
reason: string;
unread: boolean;
updated_at: Date;
last_read_at?: Date;
url: string;
subscription_url: string;
item?: GithubNotificationItem;
}
export function getGithubNotificationHtmlUrl(notification: GithubNotification): string {
return match(notification.item)
.with({ type: "GithubPullRequest", content: P.select() }, (pr) => pr.url)
.with({ type: "GithubDiscussion", content: P.select() }, (discussion) => discussion.url)
.otherwise(() => getHtmlUrlFromApiUrl(notification.subject.url) ?? getHtmlUrlFromMetadata(notification));
}
function getHtmlUrlFromApiUrl(apiUrl: string | undefined): string | undefined {
if (!apiUrl) {
return undefined;
}
const url = new URL(apiUrl);
if (url.host === "api.github.com" && url.pathname.startsWith("/repos")) {
const result = new URL(apiUrl);
result.host = "github.com";
result.pathname = url.pathname.replace("/repos", "").replace("/pulls/", "/pull/");
return result.toString();
}
return undefined;
}
function getHtmlUrlFromMetadata(notification: GithubNotification): string {
return match(notification.subject.type)
.with("CheckSuite", () => {
const result = new URL(notification.repository.url);
result.pathname = `${result.pathname}/actions`;
return result.toString();
})
.otherwise(() => notification.repository.url);
}
export interface GithubNotificationSubject {
title: string;
url?: string;
latest_comment_url?: string;
type: string;
}
export type GithubNotificationItem =
| { type: "GithubPullRequest"; content: GithubPullRequest }
| { type: "GithubDiscussion"; content: GithubDiscussion };
export interface GithubPullRequest {
id: string;
number: number;

View File

@@ -1,25 +0,0 @@
import { Detail, List, environment } from "@raycast/api";
import { useMemo } from "react";
import { NotificationActions } from "../../NotificationActions";
import { NotificationListItemProps } from "../../types";
export function GoogleMailNotificationListItem({ notification }: NotificationListItemProps) {
const icon = useMemo(() => {
if (environment.appearance === "dark") {
return "google-mail-logo-light.svg";
}
return "google-mail-logo-dark.svg";
}, [environment]);
return (
<List.Item
key={notification.id}
title={notification.title}
icon={icon}
subtitle={`#${notification.source_id}`}
actions={
<NotificationActions notification={notification} detailsTarget={<Detail markdown="# To be implemented 👋" />} />
}
/>
);
}

View File

@@ -0,0 +1,14 @@
import { GoogleMailThreadListItem } from "./GoogleMailThreadListItem";
import { NotificationListItemProps } from "../../../notification";
export function GoogleMailNotificationListItem({ notification, mutate }: NotificationListItemProps) {
if (notification.source_item.data.type !== "GoogleMailThread") return null;
return (
<GoogleMailThreadListItem
notification={notification}
googleMailThread={notification.source_item.data.content}
mutate={mutate}
/>
);
}

View File

@@ -0,0 +1,58 @@
import { GoogleMailThreadPreview } from "../preview/GoogleMailThreadPreview";
import { NotificationActions } from "../../../action/NotificationActions";
import { Notification } from "../../../notification";
import { Icon, Color, List } from "@raycast/api";
import { MutatePromise } from "@raycast/utils";
import { GoogleMailThread } from "../types";
import { Page } from "../../../types";
interface GoogleMailThreadListItemProps {
notification: Notification;
googleMailThread: GoogleMailThread;
mutate: MutatePromise<Page<Notification> | undefined>;
}
export function GoogleMailThreadListItem({ notification, googleMailThread, mutate }: GoogleMailThreadListItemProps) {
const isStarred = googleMailThread.messages.some((message) => message.labelIds?.includes("STARRED"));
const isImportant = googleMailThread.messages.some((message) => message.labelIds?.includes("IMPORTANT"));
const fromAddress = googleMailThread.messages[0].payload.headers.find((header) => header.name === "From")?.value;
const subtitle = fromAddress;
const accessories: List.Item.Accessory[] = [
{
date: new Date(notification.updated_at),
tooltip: `Updated at ${notification.updated_at}`,
},
];
if (isStarred) {
accessories.unshift({
icon: { source: Icon.Star, tintColor: Color.Yellow },
tooltip: "Starred",
});
}
if (isImportant) {
accessories.unshift({
icon: { source: Icon.Exclamationmark, tintColor: Color.Red },
tooltip: "Important",
});
}
return (
<List.Item
key={notification.id}
title={notification.title}
icon={{ source: { light: "google-mail-logo-dark.svg", dark: "google-mail-logo-light.svg" } }}
accessories={accessories}
subtitle={subtitle}
actions={
<NotificationActions
notification={notification}
detailsTarget={<GoogleMailThreadPreview notification={notification} googleMailThread={googleMailThread} />}
mutate={mutate}
/>
}
/>
);
}

View File

@@ -0,0 +1,26 @@
import { getNotificationHtmlUrl, Notification } from "../../../notification";
import { Detail, ActionPanel, Action } from "@raycast/api";
import { GoogleMailThread } from "../types";
import { useMemo } from "react";
interface GoogleMailThreadPreviewProps {
notification: Notification;
googleMailThread: GoogleMailThread;
}
export function GoogleMailThreadPreview({ notification }: GoogleMailThreadPreviewProps) {
const notificationHtmlUrl = useMemo(() => {
return getNotificationHtmlUrl(notification);
}, [notification]);
return (
<Detail
markdown={`# ${notification.title}`}
actions={
<ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} />
</ActionPanel>
}
/>
);
}

View File

@@ -0,0 +1,27 @@
export interface GoogleMailThread {
id: string;
user_email_address: string;
history_id: string;
messages: Array<GoogleMailMessage>;
}
export interface GoogleMailMessage {
id: string;
threadId: string;
labelIds?: Array<string>;
snippet: string;
payload: GoogleMailMessagePayload;
sizeEstimate: number;
historyId: string;
internalDate: Date;
}
export interface GoogleMailMessagePayload {
mimeType: string;
headers: Array<GoogleMailMessageHeader>;
}
export interface GoogleMailMessageHeader {
name: string;
value: string;
}

View File

@@ -1,25 +0,0 @@
import { Detail, List, environment } from "@raycast/api";
import { useMemo } from "react";
import { NotificationActions } from "../../NotificationActions";
import { NotificationListItemProps } from "../../types";
export function LinearNotificationListItem({ notification }: NotificationListItemProps) {
const icon = useMemo(() => {
if (environment.appearance === "dark") {
return "linear-logo-light.svg";
}
return "linear-logo-dark.svg";
}, [environment]);
return (
<List.Item
key={notification.id}
title={notification.title}
icon={icon}
subtitle={`#${notification.source_id}`}
actions={
<NotificationActions notification={notification} detailsTarget={<Detail markdown="# To be implemented 👋" />} />
}
/>
);
}

View File

@@ -0,0 +1,37 @@
import { Icon, Image, List } from "@raycast/api";
import { getAvatarIcon } from "@raycast/utils";
import { LinearUser } from "./types";
import { match } from "ts-pattern";
export function getLinearUserAccessory(user?: LinearUser): List.Item.Accessory {
if (user) {
return {
icon: user.avatar_url ? { source: user.avatar_url, mask: Image.Mask.Circle } : getAvatarIcon(user.name),
tooltip: user.name,
};
}
return { icon: Icon.Person, tooltip: "Unknown" };
}
export function getLinearNotificationReasonAccessory(notification_type: string): List.Item.Accessory {
const reason = match(notification_type)
.with("issueAddedToTriage", () => "Added To Triage")
.with("issueAddedToView", () => "Added To View")
.with("issueAssignedToYou", () => "Assigned To You")
.with("issueBlocking", () => "Blocked")
.with("issueCommentMention", () => "Comment Mention")
.with("issueCommentReaction", () => "Comment Reaction")
.with("issueCreated", () => "Created")
.with("issueDue", () => "Due")
.with("issueEmojiReaction", () => "Reaction")
.with("issueMention", () => "Mention")
.with("issueNewComment", () => "New Comment")
.with("issueStatusChanged", () => "Status Changed")
.with("issueUnassignedFromYou", () => "Unassigned From You")
.with("projectAddedAsLead", () => "Added As Lead")
.with("projectAddedAsMember", () => "Added As Member")
.with("projectUpdateCreated", () => "Update Created")
.with("projectUpdateMentionPrompt", () => "Update Mention")
.otherwise(() => notification_type);
return { tag: { value: reason } };
}

View File

@@ -0,0 +1,82 @@
import { LinearWorkflowStateType, LinearIssueNotification, LinearWorkflowState } from "../types";
import { getLinearNotificationReasonAccessory, getLinearUserAccessory } from "../accessories";
import { NotificationActions } from "../../../action/NotificationActions";
import { LinearIssuePreview } from "../preview/LinearIssuePreview";
import { Notification } from "../../../notification";
import { MutatePromise } from "@raycast/utils";
import { Page } from "../../../types";
import { match, P } from "ts-pattern";
import { List } from "@raycast/api";
interface LinearIssueNotificationListItemProps {
notification: Notification;
linearIssueNotification: LinearIssueNotification;
mutate: MutatePromise<Page<Notification> | undefined>;
}
export function LinearIssueNotificationListItem({
notification,
linearIssueNotification,
mutate,
}: LinearIssueNotificationListItemProps) {
const projectSubtitle = match(linearIssueNotification.issue.project)
.with({ name: P.select(), icon: P.nullish }, (project_name) => `/ ${project_name}`)
.with(
{ name: P.select("project_name"), icon: P.select("icon") },
({ project_name, icon }) => `/ ${icon} ${project_name}`,
)
.otherwise(() => "");
const teamSubtitle = match(linearIssueNotification.issue.team)
.with({ name: P.select(), icon: P.nullish }, (team_name) => `${team_name}`)
.with({ name: P.select("team_name"), icon: P.select("icon") }, ({ team_name, icon }) => `${icon} ${team_name}`)
.otherwise(() => "");
const subtitle = `${teamSubtitle} ${projectSubtitle} #${linearIssueNotification.issue.identifier}`;
const state = getLinearIssueStateAccessory(linearIssueNotification.issue.state);
const assignee = getLinearUserAccessory(linearIssueNotification.issue.assignee);
const reason = getLinearNotificationReasonAccessory(linearIssueNotification.type);
const accessories: List.Item.Accessory[] = [
reason,
state,
assignee,
{
date: new Date(linearIssueNotification.updated_at),
tooltip: `Updated at ${linearIssueNotification.updated_at}`,
},
];
return (
<List.Item
key={notification.id}
title={notification.title}
icon={{ source: { light: "linear-logo-dark.svg", dark: "linear-logo-light.svg" } }}
accessories={accessories}
subtitle={subtitle}
actions={
<NotificationActions
notification={notification}
detailsTarget={<LinearIssuePreview notification={notification} linearIssue={linearIssueNotification.issue} />}
mutate={mutate}
/>
}
/>
);
}
export function getLinearIssueStateAccessory(state: LinearWorkflowState): List.Item.Accessory {
return {
icon: {
source: match(state)
.with({ type: LinearWorkflowStateType.Triage }, () => "linear-issue-triage.svg")
.with({ type: LinearWorkflowStateType.Backlog }, () => "linear-issue-backlog.svg")
.with({ type: LinearWorkflowStateType.Unstarted }, () => "linear-issue-unstarted.svg")
.with({ type: LinearWorkflowStateType.Started }, () => "linear-issue-started.svg")
.with({ type: LinearWorkflowStateType.Completed }, () => "linear-issue-completed.svg")
.with({ type: LinearWorkflowStateType.Canceled }, () => "linear-issue-canceled.svg")
.exhaustive(),
tintColor: state.color,
},
tooltip: state.name,
};
}

View File

@@ -0,0 +1,28 @@
import { LinearProjectNotificationListItem } from "./LinearProjectNotificationListItem";
import { LinearIssueNotificationListItem } from "./LinearIssueNotificationListItem";
import { NotificationListItemProps } from "../../../notification";
export function LinearNotificationListItem({ notification, mutate }: NotificationListItemProps) {
if (notification.source_item.data.type !== "LinearNotification") return null;
switch (notification.source_item.data.content.type) {
case "IssueNotification":
return (
<LinearIssueNotificationListItem
notification={notification}
linearIssueNotification={notification.source_item.data.content.content}
mutate={mutate}
/>
);
case "ProjectNotification":
return (
<LinearProjectNotificationListItem
notification={notification}
linearProjectNotification={notification.source_item.data.content.content}
mutate={mutate}
/>
);
default:
return null;
}
}

View File

@@ -0,0 +1,88 @@
import { getLinearNotificationReasonAccessory, getLinearUserAccessory } from "../accessories";
import { LinearProjectNotification, LinearProjectState, LinearProject } from "../types";
import { NotificationActions } from "../../../action/NotificationActions";
import { LinearProjectPreview } from "../preview/LinearProjectPreview";
import { Notification } from "../../../notification";
import { MutatePromise } from "@raycast/utils";
import { List, Color } from "@raycast/api";
import { Page } from "../../../types";
import { match, P } from "ts-pattern";
interface LinearProjectNotificationListItemProps {
notification: Notification;
linearProjectNotification: LinearProjectNotification;
mutate: MutatePromise<Page<Notification> | undefined>;
}
export function LinearProjectNotificationListItem({
notification,
linearProjectNotification,
mutate,
}: LinearProjectNotificationListItemProps) {
const subtitle = match(linearProjectNotification.project)
.with({ name: P.select(), icon: P.nullish }, (project_name) => `${project_name}`)
.with(
{ name: P.select("project_name"), icon: P.select("icon") },
({ project_name, icon }) => `${icon} ${project_name}`,
)
.otherwise(() => "");
const state = getLinearProjectStateAccessory(linearProjectNotification.project);
const lead = getLinearUserAccessory(linearProjectNotification.project.lead);
const reason = getLinearNotificationReasonAccessory(linearProjectNotification.type);
const accessories: List.Item.Accessory[] = [
reason,
state,
lead,
{
date: new Date(linearProjectNotification.updated_at),
tooltip: `Updated at ${linearProjectNotification.updated_at}`,
},
];
return (
<List.Item
key={notification.id}
title={notification.title}
icon={{ source: { light: "linear-logo-dark.svg", dark: "linear-logo-light.svg" } }}
accessories={accessories}
subtitle={subtitle}
actions={
<NotificationActions
notification={notification}
detailsTarget={
<LinearProjectPreview notification={notification} linearProject={linearProjectNotification.project} />
}
mutate={mutate}
/>
}
/>
);
}
export function getLinearProjectStateAccessory(project: LinearProject): List.Item.Accessory {
return {
icon: match(project)
.with({ state: LinearProjectState.Planned }, () => {
return { source: "linear-project-planned.svg", tintColor: Color.SecondaryText };
})
.with({ state: LinearProjectState.Backlog }, () => {
return { source: "linear-project-backlog.svg", tintColor: Color.PrimaryText };
})
.with({ state: LinearProjectState.Started }, () => {
return { source: "linear-project-started.svg", tintColor: Color.Blue };
})
.with({ state: LinearProjectState.Paused }, () => {
return { source: "linear-project-paused.svg", tintColor: Color.PrimaryText };
})
.with({ state: LinearProjectState.Completed }, () => {
return { source: "linear-project-completed.svg", tintColor: Color.Magenta };
})
.with({ state: LinearProjectState.Canceled }, () => {
return { source: "linear-project-canceled.svg", tintColor: Color.SecondaryText };
})
.exhaustive(),
tooltip: project.state,
};
}

View File

@@ -0,0 +1,26 @@
import { getNotificationHtmlUrl, Notification } from "../../../notification";
import { Detail, ActionPanel, Action } from "@raycast/api";
import { LinearIssue } from "../types";
import { useMemo } from "react";
interface LinearIssuePreviewProps {
notification: Notification;
linearIssue: LinearIssue;
}
export function LinearIssuePreview({ notification, linearIssue }: LinearIssuePreviewProps) {
const notificationHtmlUrl = useMemo(() => {
return getNotificationHtmlUrl(notification);
}, [notification]);
return (
<Detail
markdown={`# ${linearIssue.title}`}
actions={
<ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} />
</ActionPanel>
}
/>
);
}

View File

@@ -0,0 +1,26 @@
import { getNotificationHtmlUrl, Notification } from "../../../notification";
import { Detail, ActionPanel, Action } from "@raycast/api";
import { LinearProject } from "../types";
import { useMemo } from "react";
interface LinearProjectPreviewProps {
notification: Notification;
linearProject: LinearProject;
}
export function LinearProjectPreview({ notification, linearProject }: LinearProjectPreviewProps) {
const notificationHtmlUrl = useMemo(() => {
return getNotificationHtmlUrl(notification);
}, [notification]);
return (
<Detail
markdown={`# ${linearProject.name}`}
actions={
<ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} />
</ActionPanel>
}
/>
);
}

View File

@@ -0,0 +1,133 @@
export type LinearNotification =
| { type: "IssueNotification"; content: LinearIssueNotification }
| { type: "ProjectNotification"; content: LinearProjectNotification };
export function getLinearNotificationHtmlUrl(notification: LinearNotification): string {
switch (notification.type) {
case "IssueNotification":
return notification.content.issue.url;
case "ProjectNotification":
return notification.content.project.url;
}
}
export interface LinearIssueNotification {
id: string;
type: string;
read_at?: Date;
updated_at: Date;
snoozed_until_at?: Date;
organization: LinearOrganization;
issue: LinearIssue;
}
export interface LinearOrganization {
name: string;
key: string;
logo_url?: string;
}
export interface LinearIssue {
id: string;
created_at: Date;
updated_at: Date;
completed_at?: Date;
canceled_at?: Date;
due_date?: Date;
identifier: string;
title: string;
url: string;
priority: LinearIssuePriority;
project?: LinearProject;
project_milestone?: LinearProjectMilestone;
creator?: LinearUser;
assignee?: LinearUser;
state: LinearWorkflowState;
labels: Array<LinearLabel>;
description: string;
team: LinearTeam;
}
export function getLinearIssueHtmlUrl(linearIssue: LinearIssue): string {
return linearIssue.url;
}
export enum LinearIssuePriority {
NoPriority = 0,
Urgent = 1,
High = 2,
Normal = 3,
Low = 4,
}
export interface LinearProject {
id: string;
name: string;
url: string;
description: string;
icon?: string;
color: string;
state: LinearProjectState;
progress: number; // percentage between 0 and 100
start_date?: Date;
target_date?: Date;
lead?: LinearUser;
}
export enum LinearProjectState {
Planned = "Planned",
Backlog = "Backlog",
Started = "Started",
Paused = "Paused",
Completed = "Completed",
Canceled = "Canceled",
}
export interface LinearProjectMilestone {
name: string;
description?: string;
}
export interface LinearUser {
name: string;
avatar_url?: string;
url: string;
}
export interface LinearWorkflowState {
name: string;
description?: string;
color: string;
type: LinearWorkflowStateType;
}
export enum LinearWorkflowStateType {
Triage = "Triage",
Backlog = "Backlog",
Unstarted = "Unstarted",
Started = "Started",
Completed = "Completed",
Canceled = "Canceled",
}
export interface LinearLabel {
name: string;
description?: string;
color: string;
}
export interface LinearTeam {
id: string;
key: string;
name: string;
}
export interface LinearProjectNotification {
id: string;
type: string;
read_at?: Date;
updated_at: Date;
snoozed_until_at?: Date;
organization: LinearOrganization;
project: LinearProject;
}

View File

@@ -0,0 +1,42 @@
import { SlackIcon, SlackUser } from "./types";
export function getSlackUserAvatarUrl(slackUser: SlackUser): string | null {
if (!slackUser.profile) {
return null;
}
if (slackUser.profile.image_24) {
return slackUser.profile.image_24;
}
if (slackUser.profile.image_32) {
return slackUser.profile.image_32;
}
if (slackUser.profile.image_34) {
return slackUser.profile.image_34;
}
if (slackUser.profile.image_44) {
return slackUser.profile.image_44;
}
if (slackUser.profile.image_48) {
return slackUser.profile.image_48;
}
return null;
}
export function getSlackIconUrl(slackIcon?: SlackIcon): string | null {
if (slackIcon?.image_24) {
return slackIcon.image_24;
}
if (slackIcon?.image_32) {
return slackIcon.image_32;
}
if (slackIcon?.image_34) {
return slackIcon.image_34;
}
if (slackIcon?.image_44) {
return slackIcon.image_44;
}
if (slackIcon?.image_48) {
return slackIcon.image_48;
}
return null;
}

View File

@@ -0,0 +1,35 @@
import { SlackReactionNotificationListItem } from "./SlackReactionNotificationListItem";
import { SlackThreadNotificationListItem } from "./SlackThreadNotificationListItem";
import { SlackStarNotificationListItem } from "./SlackStarNotificationListItem";
import { NotificationListItemProps } from "../../../notification";
export function SlackNotificationListItem({ notification, mutate }: NotificationListItemProps) {
switch (notification.source_item.data.type) {
case "SlackStar":
return (
<SlackStarNotificationListItem
notification={notification}
slack_star={notification.source_item.data.content}
mutate={mutate}
/>
);
case "SlackReaction":
return (
<SlackReactionNotificationListItem
notification={notification}
slack_reaction={notification.source_item.data.content}
mutate={mutate}
/>
);
case "SlackThread":
return (
<SlackThreadNotificationListItem
notification={notification}
slack_thread={notification.source_item.data.content}
mutate={mutate}
/>
);
default:
return null;
}
}

View File

@@ -0,0 +1,117 @@
import { NotificationActions } from "../../../action/NotificationActions";
import { SlackReactionPreview } from "../preview/SlackReactionPreview";
import { SlackBotInfo, SlackReaction, SlackUser } from "../types";
import { getAvatarIcon, MutatePromise } from "@raycast/utils";
import { getSlackIconUrl, getSlackUserAvatarUrl } from "..";
import { Notification } from "../../../notification";
import { Icon, Image, List } from "@raycast/api";
import { Page } from "../../../types";
import { match, P } from "ts-pattern";
export type SlackReactionNotificationListItemProps = {
notification: Notification;
slack_reaction: SlackReaction;
mutate: MutatePromise<Page<Notification> | undefined>;
};
export function SlackReactionNotificationListItem({
notification,
slack_reaction,
mutate,
}: SlackReactionNotificationListItemProps) {
const subtitle = getSlackReactionNotificationSubtitle(slack_reaction);
const author = getSlackReactionAuthorAccessory(slack_reaction);
const team = getSlackReactionTeamAccessory(slack_reaction);
const updated_at = "2023-01-01"; // TODO
const accessories: List.Item.Accessory[] = [{ date: new Date(updated_at), tooltip: `Updated at ${updated_at}` }];
if (author) {
accessories.unshift(author);
}
if (team) {
accessories.unshift(team);
}
return (
<List.Item
key={notification.id}
title={notification.title}
icon={{ source: { light: "slack-logo-dark.svg", dark: "slack-logo-light.svg" } }}
subtitle={subtitle}
accessories={accessories}
actions={
<NotificationActions
notification={notification}
detailsTarget={<SlackReactionPreview notification={notification} slack_reaction={slack_reaction} />}
mutate={mutate}
/>
}
/>
);
}
function getSlackReactionNotificationSubtitle(slack_reaction: SlackReaction): string {
return match(slack_reaction.item)
.with(
{
type: P.union("Message", "File"),
content: P.select(),
},
(slack_reaction_item) => {
const channelName = slack_reaction_item.channel?.name;
return channelName ? `#${channelName}` : "";
},
)
.otherwise(() => "");
}
function getSlackReactionAuthorAccessory(slack_reaction: SlackReaction): List.Item.Accessory | null {
return match(slack_reaction.item)
.with(
{
type: "Message",
content: P.select(),
},
(slackMessageDetails) => {
return match(slackMessageDetails.sender)
.with({ type: "User", content: P.select() }, (slackUser: SlackUser) => {
const userAvatarUrl = getSlackUserAvatarUrl(slackUser);
const userName = slackUser.real_name || "Unknown";
return {
icon: userAvatarUrl ? { source: userAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(userName),
tooltip: userName,
};
})
.with({ type: "Bot", content: P.select() }, (slackBot: SlackBotInfo) => {
const botAvatarUrl = getSlackIconUrl(slackBot.icons);
return {
icon: botAvatarUrl ? { source: botAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(slackBot.name),
tooltip: slackBot.name,
};
})
.otherwise(() => ({ icon: Icon.Person, tooltip: "Unknown" }));
},
)
.otherwise(() => null);
}
function getSlackReactionTeamAccessory(slack_reaction: SlackReaction): List.Item.Accessory | null {
return match(slack_reaction.item)
.with(
{
type: P.union("Message", "File"),
content: P.select(),
},
(slack_reaction_item) => {
const teamName = slack_reaction_item.team.name;
const teamIconUrl = getSlackIconUrl(slack_reaction_item.team.icon);
if (!teamName || !teamIconUrl) {
return null;
}
return { icon: { source: teamIconUrl, mask: Image.Mask.Circle }, tooltip: teamName };
},
)
.otherwise(() => null);
}

View File

@@ -0,0 +1,117 @@
import { NotificationActions } from "../../../action/NotificationActions";
import { SlackStarPreview } from "../preview/SlackStarPreview";
import { getAvatarIcon, MutatePromise } from "@raycast/utils";
import { SlackBotInfo, SlackStar, SlackUser } from "../types";
import { getSlackIconUrl, getSlackUserAvatarUrl } from "..";
import { Notification } from "../../../notification";
import { Icon, Image, List } from "@raycast/api";
import { Page } from "../../../types";
import { match, P } from "ts-pattern";
export type SlackStarNotificationListItemProps = {
notification: Notification;
slack_star: SlackStar;
mutate: MutatePromise<Page<Notification> | undefined>;
};
export function SlackStarNotificationListItem({
notification,
slack_star,
mutate,
}: SlackStarNotificationListItemProps) {
const subtitle = getSlackStarNotificationSubtitle(slack_star);
const author = getSlackStarAuthorAccessory(slack_star);
const team = getSlackStarTeamAccessory(slack_star);
const updated_at = "2023-01-01"; // TODO
const accessories: List.Item.Accessory[] = [{ date: new Date(updated_at), tooltip: `Updated at ${updated_at}` }];
if (author) {
accessories.unshift(author);
}
if (team) {
accessories.unshift(team);
}
return (
<List.Item
key={notification.id}
title={notification.title}
icon={{ source: { light: "slack-logo-dark.svg", dark: "slack-logo-light.svg" } }}
subtitle={subtitle}
accessories={accessories}
actions={
<NotificationActions
notification={notification}
detailsTarget={<SlackStarPreview notification={notification} slack_star={slack_star} />}
mutate={mutate}
/>
}
/>
);
}
function getSlackStarNotificationSubtitle(slack_star: SlackStar): string {
return match(slack_star.item)
.with(
{
type: P.union("Message", "File", "FileComment", "Channel", "Im", "Group"),
content: P.select(),
},
(slack_star_item) => {
const channelName = slack_star_item.channel?.name;
return channelName ? `#${channelName}` : "";
},
)
.otherwise(() => "");
}
function getSlackStarAuthorAccessory(slack_star: SlackStar): List.Item.Accessory | null {
return match(slack_star.item)
.with(
{
type: "Message",
content: P.select(),
},
(slackMessageDetails) => {
return match(slackMessageDetails.sender)
.with({ type: "User", content: P.select() }, (slackUser: SlackUser) => {
const userAvatarUrl = getSlackUserAvatarUrl(slackUser);
const userName = slackUser.real_name || "Unknown";
return {
icon: userAvatarUrl ? { source: userAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(userName),
tooltip: userName,
};
})
.with({ type: "Bot", content: P.select() }, (slackBot: SlackBotInfo) => {
const botAvatarUrl = getSlackIconUrl(slackBot.icons);
return {
icon: botAvatarUrl ? { source: botAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(slackBot.name),
tooltip: slackBot.name,
};
})
.otherwise(() => ({ icon: Icon.Person, tooltip: "Unknown" }));
},
)
.otherwise(() => null);
}
function getSlackStarTeamAccessory(slack_star: SlackStar): List.Item.Accessory | null {
return match(slack_star.item)
.with(
{
type: P.union("Message", "File", "FileComment", "Channel", "Im", "Group"),
content: P.select(),
},
(slack_star_item) => {
const teamName = slack_star_item.team.name;
const teamIconUrl = getSlackIconUrl(slack_star_item.team.icon);
if (!teamName || !teamIconUrl) {
return null;
}
return { icon: { source: teamIconUrl, mask: Image.Mask.Circle }, tooltip: teamName };
},
)
.otherwise(() => null);
}

View File

@@ -0,0 +1,99 @@
import { NotificationActions } from "../../../action/NotificationActions";
import { SlackThreadPreview } from "../preview/SlackThreadPreview";
import { getAvatarIcon, MutatePromise } from "@raycast/utils";
import { getSlackIconUrl, getSlackUserAvatarUrl } from "..";
import { Notification } from "../../../notification";
import { Icon, Image, List } from "@raycast/api";
import { SlackThread } from "../types";
import { Page } from "../../../types";
export type SlackThreadNotificationListItemProps = {
notification: Notification;
slack_thread: SlackThread;
mutate: MutatePromise<Page<Notification> | undefined>;
};
export function SlackThreadNotificationListItem({
notification,
slack_thread,
mutate,
}: SlackThreadNotificationListItemProps) {
const subtitle = getSlackThreadNotificationSubtitle(slack_thread);
const author = getSlackThreadAuthorAccessory(slack_thread);
const team = getSlackThreadTeamAccessory(slack_thread);
const updated_at = "2023-01-01"; // TODO
const accessories: List.Item.Accessory[] = [{ date: new Date(updated_at), tooltip: `Updated at ${updated_at}` }];
if (author) {
accessories.unshift(author);
}
if (team) {
accessories.unshift(team);
}
return (
<List.Item
key={notification.id}
title={notification.title}
icon={{ source: { light: "slack-logo-dark.svg", dark: "slack-logo-light.svg" } }}
subtitle={subtitle}
accessories={accessories}
actions={
<NotificationActions
notification={notification}
detailsTarget={<SlackThreadPreview notification={notification} slack_thread={slack_thread} />}
mutate={mutate}
/>
}
/>
);
}
function getSlackThreadNotificationSubtitle(slack_thread: SlackThread): string {
const channelName = slack_thread.channel?.name;
return channelName ? `in #${channelName}` : "";
}
function getSlackThreadAuthorAccessory(slack_thread: SlackThread): List.Item.Accessory | null {
const firstUnreadMessage = slack_thread.messages[0];
if (firstUnreadMessage.user) {
const profile = slack_thread.sender_profiles[firstUnreadMessage.user];
if (profile && profile.type === "User") {
const slackUser = profile.content;
const userAvatarUrl = getSlackUserAvatarUrl(slackUser);
const userName = slackUser.real_name || "Unknown";
return {
icon: userAvatarUrl ? { source: userAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(userName),
tooltip: userName,
};
}
}
if (firstUnreadMessage.bot_id) {
const profile = slack_thread.sender_profiles[firstUnreadMessage.bot_id];
if (profile && profile.type === "Bot") {
const slackBot = profile.content;
const botAvatarUrl = getSlackIconUrl(slackBot.icons);
return {
icon: botAvatarUrl ? { source: botAvatarUrl, mask: Image.Mask.Circle } : getAvatarIcon(slackBot.name),
tooltip: slackBot.name,
};
}
}
return { icon: Icon.Person, tooltip: "Unknown" };
}
function getSlackThreadTeamAccessory(slack_thread: SlackThread): List.Item.Accessory | null {
const teamName = slack_thread.team.name;
const teamIconUrl = getSlackIconUrl(slack_thread.team.icon);
if (!teamName || !teamIconUrl) {
return null;
}
return { icon: { source: teamIconUrl, mask: Image.Mask.Circle }, tooltip: teamName };
}

View File

@@ -0,0 +1,26 @@
import { getNotificationHtmlUrl, Notification } from "../../../notification";
import { Detail, ActionPanel, Action } from "@raycast/api";
import { SlackReaction } from "../types";
import { useMemo } from "react";
interface SlackReactionPreviewProps {
notification: Notification;
slack_reaction: SlackReaction;
}
export function SlackReactionPreview({ notification }: SlackReactionPreviewProps) {
const notificationHtmlUrl = useMemo(() => {
return getNotificationHtmlUrl(notification);
}, [notification]);
return (
<Detail
markdown={`# ${notification.title}`}
actions={
<ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} />
</ActionPanel>
}
/>
);
}

View File

@@ -0,0 +1,26 @@
import { getNotificationHtmlUrl, Notification } from "../../../notification";
import { Detail, ActionPanel, Action } from "@raycast/api";
import { SlackStar } from "../types";
import { useMemo } from "react";
interface SlackStarPreviewProps {
notification: Notification;
slack_star: SlackStar;
}
export function SlackStarPreview({ notification }: SlackStarPreviewProps) {
const notificationHtmlUrl = useMemo(() => {
return getNotificationHtmlUrl(notification);
}, [notification]);
return (
<Detail
markdown={`# ${notification.title}`}
actions={
<ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} />
</ActionPanel>
}
/>
);
}

View File

@@ -0,0 +1,26 @@
import { getNotificationHtmlUrl, Notification } from "../../../notification";
import { Detail, ActionPanel, Action } from "@raycast/api";
import { SlackThread } from "../types";
import { useMemo } from "react";
interface SlackThreadPreviewProps {
notification: Notification;
slack_thread: SlackThread;
}
export function SlackThreadPreview({ notification }: SlackThreadPreviewProps) {
const notificationHtmlUrl = useMemo(() => {
return getNotificationHtmlUrl(notification);
}, [notification]);
return (
<Detail
markdown={`# ${notification.title}`}
actions={
<ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} />
</ActionPanel>
}
/>
);
}

View File

@@ -0,0 +1,493 @@
import { match, P } from "ts-pattern";
export interface SlackStar {
state: SlackStarState;
created_at: Date;
item: SlackStarItem;
}
export function getSlackStarHtmlUrl(slack_star: SlackStar): string {
return match(slack_star.item)
.with({ type: "Message", content: P.select() }, (message) => message.url)
.with(
{ type: "File", content: P.select() },
(file) => `https://app.slack.com/client/${file.team.id}/${file.channel.id}`,
)
.with(
{ type: "FileComment", content: P.select() },
(fileComment) => `https://app.slack.com/client/${fileComment.team.id}/${fileComment.channel.id}`,
)
.with(
{ type: "Channel", content: P.select() },
(channel) => `https://app.slack.com/client/${channel.team.id}/${channel.channel.id}`,
)
.with({ type: "Im", content: P.select() }, (im) => `https://app.slack.com/client/${im.team.id}/${im.channel.id}`)
.with(
{ type: "Group", content: P.select() },
(group) => `https://app.slack.com/client/${group.team.id}/${group.channel.id}`,
)
.otherwise(() => "");
}
export enum SlackStarState {
StarAdded = "StarAdded",
StarRemoved = "StarRemoved",
}
export type SlackStarItem =
| { type: "Message"; content: SlackMessageDetails }
| { type: "File"; content: SlackFileDetails }
| { type: "FileComment"; content: SlackFileCommentDetails }
| { type: "Channel"; content: SlackChannelDetails }
| { type: "Im"; content: SlackImDetails }
| { type: "Group"; content: SlackGroupDetails };
export interface SlackReaction {
name: string;
state: SlackReactionState;
created_at: Date;
item: SlackReactionItem;
}
export function getSlackReactionHtmlUrl(slack_reaction: SlackReaction): string {
return match(slack_reaction.item)
.with({ type: "Message", content: P.select() }, (message) => message.url)
.with(
{ type: "File", content: P.select() },
(file) => `https://app.slack.com/client/${file.team.id}/${file.channel.id}`,
)
.otherwise(() => "");
}
export enum SlackReactionState {
ReactionAdded = "ReactionAdded",
ReactionRemoved = "ReactionRemoved",
}
export type SlackReactionItem =
| { type: "Message"; content: SlackMessageDetails }
| { type: "File"; content: SlackFileDetails };
export interface SlackThread {
url: string;
messages: Array<SlackHistoryMessage>;
sender_profiles: Record<string, SlackMessageSenderDetails>;
subscribed: boolean;
last_read?: string;
channel: SlackChannelInfo;
team: SlackTeamInfo;
references?: SlackReferences;
}
export function getSlackThreadHtmlUrl(slack_thread: SlackThread): string {
return slack_thread.url;
}
export interface SlackReferences {
channels: Record<string, string | null>;
users: Record<string, string | null>;
usergroups: Record<string, string | null>;
}
export interface SlackPushEventCallback {
team_id: string;
api_app_id: string;
event: SlackEventCallbackBody;
event_id: string;
event_time: Date;
event_context?: string;
authed_users?: Array<string>;
authorizations?: Array<SlackEventAuthorization>;
}
export interface SlackEventAuthorization {
team_id: string;
user_id: string;
is_bot: boolean;
}
export type SlackEventCallbackBody =
| { type: "SarAdded"; content: SlackStarAddedEvent }
| { type: "StarRemoved"; content: SlackStarRemovedEvent };
export interface SlackStarAddedEvent {
user: string;
item: SlackStarsItem;
event_ts: Date;
}
export interface SlackStarRemovedEvent {
user: string;
item: SlackStarsItem;
event_ts: Date;
}
export type SlackStarsItem =
| { type: "Message"; content: SlackStarsItemMessage }
| { type: "File"; content: SlackStarsItemFile }
| { type: "FileComment"; content: SlackStarsItemFileComment }
| { type: "Channel"; content: SlackStarsItemChannel }
| { type: "Im"; content: SlackStarsItemIm }
| { type: "Group"; content: SlackStarsItemGroup };
export interface SlackStarsItemMessage {
message: SlackHistoryMessage;
channel: string;
date_create: Date;
}
export interface SlackHistoryMessage {
ts: string;
channel?: string;
channel_type?: string;
thread_ts?: string;
client_msg_id?: string;
user?: string;
bot_id?: string;
text?: string;
blocks?: Array<SlackBlock>;
attachments?: Array<SlackMessageAttachment>;
upload?: boolean;
files?: Array<SlackFile>;
reactions?: Array<SlackReactionDetails>;
}
export interface SlackReactionDetails {
name: string;
count: number;
users: Array<string>;
}
export interface SlackMessageAttachment {
id?: number;
color?: string;
fallback?: string;
title?: string;
fields?: Array<SlackMessageAttachmentFieldObject>;
mrkdwn_in?: Array<string>;
text?: string;
}
export interface SlackMessageAttachmentFieldObject {
title?: string;
value?: string;
short?: boolean;
}
export type SlackBlock =
| SlackSectionBlock
| SlackHeaderBlock
| SlackDividerBlock
| SlackImageBlock
| SlackActionsBlock
| SlackContextBlock
| SlackInputBlock
| SlackFileBlock
| { type: "rich_text" }
| { type: "event" };
export interface SlackSectionBlock {
type: "section";
block_id?: string;
text?: SlackBlockText;
fields?: Array<SlackBlockText>;
// To be specified
// eslint-disable-next-line @typescript-eslint/no-explicit-any
accessory?: any;
}
export type SlackBlockText =
| { type: "plain_text"; value: string }
| { type: "mrkdwn"; text: string; verbatim?: boolean };
export interface SlackHeaderBlock {
type: "header";
block_id?: string;
text: SlackBlockText;
}
export interface SlackDividerBlock {
type: "divider";
block_id?: string;
}
export interface SlackImageBlock {
type: "image";
block_id?: string;
image_url: string;
alt_text: string;
title?: SlackBlockText;
}
export interface SlackActionsBlock {
type: "actions";
block_id?: string;
// To be specified
// eslint-disable-next-line @typescript-eslint/no-explicit-any
elements: Array<any>;
}
export interface SlackContextBlock {
type: "context";
block_id?: string;
// To be specified
// eslint-disable-next-line @typescript-eslint/no-explicit-any
elements: Array<any>;
}
export interface SlackInputBlock {
type: "input";
block_id?: string;
label: SlackBlockText;
// To be specified
// eslint-disable-next-line @typescript-eslint/no-explicit-any
element: any;
hint?: SlackBlockText;
optional?: boolean;
dispatch_action?: boolean;
}
export interface SlackFileBlock {
type: "file";
block_id?: string;
external_id: string;
source: string;
}
export interface SlackStarsItemFile {
file: SlackFile;
channel: string;
date_create: Date;
}
export interface SlackFile {
id: string;
created?: Date;
timestamp?: Date;
name?: string;
title?: string;
mimetype?: string;
filetype?: string;
pretty_type?: string;
external_type?: string;
user?: string;
username?: string;
url_private?: string;
url_private_download?: string;
permalink?: string;
permalink_public?: string;
reactions?: Array<SlackReactionDetails>;
editable?: boolean;
is_external?: boolean;
is_public?: boolean;
public_url_shared?: boolean;
display_as_bot?: boolean;
is_starred?: boolean;
has_rich_preview?: boolean;
}
export interface SlackStarsItemFileComment {
file: SlackFile;
file_comment: string;
channel: string;
date_create: Date;
}
export interface SlackStarsItemChannel {
channel: string;
date_create: Date;
}
export interface SlackStarsItemIm {
channel: string;
date_create: Date;
}
export interface SlackStarsItemGroup {
group: string;
date_create: Date;
}
export interface SlackMessageDetails {
url: string;
message: SlackHistoryMessage;
channel: SlackChannelInfo;
sender: SlackMessageSenderDetails;
team: SlackTeamInfo;
}
export type SlackMessageSenderDetails = { type: "User"; content: SlackUser } | { type: "Bot"; content: SlackBotInfo };
export interface SlackUser {
id: string;
team_id?: string;
name?: string;
locale?: string;
profile?: SlackUserProfile;
is_admin?: boolean;
is_app_user?: boolean;
is_bot?: boolean;
is_invited_user?: boolean;
is_owner?: boolean;
is_primary_owner?: boolean;
is_restricted?: boolean;
is_stranger?: boolean;
is_ultra_restricted?: boolean;
has_2fa?: boolean;
tz?: string;
tz_label?: string;
tz_offset?: number;
updated?: Date;
deleted?: boolean;
color?: string;
real_name?: string;
enterprise_user?: SlackEnterpriseUser;
}
export interface SlackUserProfile {
id?: string;
display_name?: string;
real_name?: string;
real_name_normalized?: string;
avatar_hash?: string;
status_text?: string;
status_expiration?: Date;
status_emoji?: string;
display_name_normalized?: string;
email?: string;
team?: string;
image_original?: string;
image_default?: boolean;
image_24?: string;
image_32?: string;
image_34?: string;
image_44?: string;
image_48?: string;
image_68?: string;
image_72?: string;
image_88?: string;
image_102?: string;
image_132?: string;
image_192?: string;
image_230?: string;
image_512?: string;
}
export interface SlackEnterpriseUser {
id: string;
enterprise_id: string;
enterprise_name?: string;
teams?: Array<string>;
is_admin?: boolean;
is_app_user?: boolean;
is_bot?: boolean;
is_invited_user?: boolean;
is_owner?: boolean;
is_primary_owner?: boolean;
is_restricted?: boolean;
is_stranger?: boolean;
is_ultra_restricted?: boolean;
has_2fa?: boolean;
}
export interface SlackBotInfo {
id?: string;
name: string;
updated?: Date;
app_id: string;
user_id: string;
icons?: SlackIcon;
}
export interface SlackTeamInfo {
id: string;
name?: string;
domain?: string;
email_domain?: string;
icon?: SlackIcon;
}
export interface SlackIcon {
image_original?: string;
image_default?: boolean;
image_24?: string;
image_32?: string;
image_34?: string;
image_44?: string;
image_48?: string;
image_68?: string;
image_72?: string;
image_88?: string;
image_102?: string;
image_132?: string;
image_192?: string;
image_230?: string;
image_512?: string;
}
export interface SlackChannelInfo {
id: string;
created: Date;
creator?: string;
name?: string;
name_normalized?: string;
topic?: SlackChannelTopicInfo;
purpose?: SlackChannelPurposeInfo;
previous_names?: Array<string>;
priority?: number;
num_members?: number;
locale?: string;
is_channel?: boolean;
is_group?: boolean;
is_im?: boolean;
is_archived?: boolean;
is_general?: boolean;
is_shared?: boolean;
is_org_shared?: boolean;
is_member?: boolean;
is_private?: boolean;
is_mpim?: boolean;
is_user_deleted?: boolean;
last_read?: string;
unread_count?: number;
unread_count_display?: number;
}
export interface SlackChannelTopicInfo {
value: string;
creator?: string;
last_set?: Date;
}
export interface SlackChannelPurposeInfo {
value: string;
creator?: string;
last_set?: Date;
}
export interface SlackChannelDetails {
channel: SlackChannelInfo;
team: SlackTeamInfo;
}
export interface SlackFileDetails {
channel: SlackChannelInfo;
sender?: SlackUser;
team: SlackTeamInfo;
}
export interface SlackFileCommentDetails {
channel: SlackChannelInfo;
team: SlackTeamInfo;
}
export interface SlackImDetails {
channel: SlackChannelInfo;
team: SlackTeamInfo;
}
export interface SlackGroupDetails {
channel: SlackChannelInfo;
team: SlackTeamInfo;
}

View File

@@ -1,28 +0,0 @@
import { Detail, List, environment } from "@raycast/api";
import { useMemo } from "react";
import { NotificationTaskActions } from "../../NotificationTaskActions";
import { NotificationListItemProps } from "../../types";
export function TodoistNotificationListItem({ notification }: NotificationListItemProps) {
const icon = useMemo(() => {
if (environment.appearance === "dark") {
return "todoist-icon-light.svg";
}
return "todoist-icon-dark.svg";
}, [environment]);
return (
<List.Item
key={notification.id}
title={notification.title}
icon={icon}
subtitle={`#${notification.source_id}`}
actions={
<NotificationTaskActions
notification={notification}
detailsTarget={<Detail markdown="# To be implemented 👋" />}
/>
}
/>
);
}

View File

@@ -0,0 +1,52 @@
import { NotificationTaskActions } from "../../../action/NotificationTaskActions";
import { TodoistTaskPreview } from "../preview/TodoistTaskPreview";
import { NotificationListItemProps } from "../../../notification";
import { Icon, List, Color } from "@raycast/api";
import { TaskPriority } from "../../../task";
import { match } from "ts-pattern";
import dayjs from "dayjs";
export function TodoistNotificationListItem({ notification, mutate }: NotificationListItemProps) {
const dueAt = notification.task?.due_at?.content;
const subtitle = dueAt ? dayjs(dueAt).format("YYYY-MM-DD") : undefined;
const color = match(notification)
.with({ task: { priority: TaskPriority.P1 } }, () => Color.Red)
.with({ task: { priority: TaskPriority.P2 } }, () => Color.Orange)
.with({ task: { priority: TaskPriority.P3 } }, () => Color.Yellow)
.otherwise(() => null);
const accessories: List.Item.Accessory[] = [
{
icon: { source: Icon.Circle, tintColor: color },
},
{
date: new Date(notification.updated_at),
tooltip: `Updated at ${notification.updated_at}`,
},
];
const task = notification.task;
if (task) {
for (const tag of task.tags) {
accessories.unshift({ tag: { value: tag } });
}
}
return (
<List.Item
key={notification.id}
title={notification.title}
icon={{ source: { light: "todoist-logo-dark.svg", dark: "todoist-logo-light.svg" } }}
subtitle={subtitle}
accessories={accessories}
actions={
<NotificationTaskActions
notification={notification}
detailsTarget={<TodoistTaskPreview notification={notification} />}
mutate={mutate}
/>
}
/>
);
}

View File

@@ -0,0 +1,24 @@
import { getNotificationHtmlUrl, Notification } from "../../../notification";
import { Detail, ActionPanel, Action } from "@raycast/api";
import { useMemo } from "react";
interface TodoistTaskPreviewProps {
notification: Notification;
}
export function TodoistTaskPreview({ notification }: TodoistTaskPreviewProps) {
const notificationHtmlUrl = useMemo(() => {
return getNotificationHtmlUrl(notification);
}, [notification]);
return (
<Detail
markdown={`# ${notification.title}`}
actions={
<ActionPanel>
<Action.OpenInBrowser url={notificationHtmlUrl} />
</ActionPanel>
}
/>
);
}

View File

@@ -0,0 +1,3 @@
export interface TodoistItem {
id: string;
}

View File

@@ -1,14 +1,46 @@
import { Notification } from "./types";
import { getThirdPartyItemHtmlUrl, ThirdPartyItem } from "./third_party_item";
import { MutatePromise } from "@raycast/utils";
import { Page } from "./types";
import { Task } from "./task";
export function getNotificationHtmlUrl(notification: Notification) {
switch (notification.details?.type) {
case "GithubPullRequest":
return notification.details.content.url;
case "GithubDiscussion":
return notification.details.content.url;
default: {
// TODO
return "https://github.com";
export interface Notification {
id: string;
title: string;
status: NotificationStatus;
created_at: Date;
updated_at: Date;
last_read_at?: Date;
snoozed_until?: Date;
user_id: string;
task?: Task;
kind: NotificationSourceKind;
source_item: ThirdPartyItem;
}
export enum NotificationSourceKind {
Github = "Github",
Todoist = "Todoist",
Linear = "Linear",
GoogleMail = "GoogleMail",
Slack = "Slack",
}
export enum NotificationStatus {
Unread = "Unread",
Read = "Read",
Deleted = "Deleted",
Unsubscribed = "Unsubscribed",
}
export type NotificationListItemProps = {
notification: Notification;
mutate: MutatePromise<Page<Notification> | undefined>;
};
export function getNotificationHtmlUrl(notification: Notification): string {
return getThirdPartyItemHtmlUrl(notification.source_item);
}
export function isNotificationBuiltFromTask(notification: Notification) {
return notification.kind === NotificationSourceKind.Todoist;
}

41
src/task.ts Normal file
View File

@@ -0,0 +1,41 @@
export interface Task {
id: string;
source_id: string;
title: string;
body: string;
status: TaskStatus;
completed_at?: Date;
priority: TaskPriority;
due_at?: DueDate;
tags: Array<string>;
parent_id?: string;
project: string;
is_recurring: boolean;
created_at: Date;
metadata: TaskMetadata;
user_id: string;
}
export type TaskMetadata = {
type: "Todoist";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: any;
};
export enum TaskStatus {
Active = "Active",
Done = "Done",
Deleted = "Deleted",
}
export enum TaskPriority {
P1 = 1,
P2 = 2,
P3 = 3,
P4 = 4,
}
export type DueDate =
| { type: "Date"; content: Date }
| { type: "DateTime"; content: Date }
| { type: "DateTimeWithTz"; content: Date };

58
src/third_party_item.ts Normal file
View File

@@ -0,0 +1,58 @@
import {
getSlackReactionHtmlUrl,
getSlackStarHtmlUrl,
getSlackThreadHtmlUrl,
SlackReaction,
SlackStar,
SlackThread,
} from "./integrations/slack/types";
import {
getLinearIssueHtmlUrl,
getLinearNotificationHtmlUrl,
LinearIssue,
LinearNotification,
} from "./integrations/linear/types";
import { getGithubNotificationHtmlUrl, GithubNotification } from "./integrations/github/types";
import { GoogleMailThread } from "./integrations/google-mail/types";
import { TodoistItem } from "./integrations/todoist/types";
export interface ThirdPartyItem {
id: string;
source_id: string;
data: ThirdPartyItemData;
created_at: Date;
updated_at: Date;
user_id: string;
integration_connection_id: string;
}
export type ThirdPartyItemData =
| { type: "TodoistItem"; content: TodoistItem }
| { type: "SlackStar"; content: SlackStar }
| { type: "SlackReaction"; content: SlackReaction }
| { type: "SlackThread"; content: SlackThread }
| { type: "LinearIssue"; content: LinearIssue }
| { type: "LinearNotification"; content: LinearNotification }
| { type: "GithubNotification"; content: GithubNotification }
| { type: "GoogleMailThread"; content: GoogleMailThread };
export function getThirdPartyItemHtmlUrl(thirdPartyItem: ThirdPartyItem): string {
switch (thirdPartyItem.data.type) {
case "TodoistItem":
return `https://todoist.com/showTask?id=${thirdPartyItem.data.content.id}`;
case "SlackStar":
return getSlackStarHtmlUrl(thirdPartyItem.data.content);
case "SlackReaction":
return getSlackReactionHtmlUrl(thirdPartyItem.data.content);
case "SlackThread":
return getSlackThreadHtmlUrl(thirdPartyItem.data.content);
case "LinearIssue":
return getLinearIssueHtmlUrl(thirdPartyItem.data.content);
case "LinearNotification":
return getLinearNotificationHtmlUrl(thirdPartyItem.data.content);
case "GithubNotification":
return getGithubNotificationHtmlUrl(thirdPartyItem.data.content);
case "GoogleMailThread":
return `https://mail.google.com/mail/u/0/#inbox/${thirdPartyItem.data.content.id}`;
}
}

View File

@@ -1,5 +1,3 @@
import { GithubDiscussion, GithubPullRequest } from "./integrations/github/types";
export interface UniversalInboxPreferences {
apiKey: string;
universalInboxBaseUrl: string;
@@ -11,38 +9,3 @@ export interface Page<T> {
total: number;
content: Array<T>;
}
export interface Notification {
id: string;
title: string;
source_id: string;
status: NotificationStatus;
metadata: NotificationMetadata;
updated_at: Date;
last_read_at?: Date;
snoozed_until?: Date;
user_id: string;
task_id?: string;
details?: NotificationDetails;
}
export type NotificationMetadata = {
type: "Github" | "Linear" | "GoogleMail" | "Todoist";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: any;
};
export type NotificationDetails =
| { type: "GithubPullRequest"; content: GithubPullRequest }
| { type: "GithubDiscussion"; content: GithubDiscussion };
export enum NotificationStatus {
Unread = "Unread",
Read = "Read",
Deleted = "Deleted",
Unsubscribed = "Unsubscribed",
}
export type NotificationListItemProps = {
notification: Notification;
};