diff --git a/resources/sql/patches/053.feed.sql b/resources/sql/patches/053.feed.sql new file mode 100644 index 0000000000..17dd8b2da7 --- /dev/null +++ b/resources/sql/patches/053.feed.sql @@ -0,0 +1,21 @@ +CREATE DATABASE phabricator_feed; + +CREATE TABLE phabricator_feed.feed_storydata ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARCHAR(64) BINARY NOT NULL, + UNIQUE KEY (phid), + chronologicalKey BIGINT UNSIGNED NOT NULL, + UNIQUE KEY (chronologicalKey), + storyType varchar(64) NOT NULL, + storyData LONGBLOB NOT NULL, + authorPHID varchar(64) BINARY NOT NULL, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL +); + +CREATE TABLE phabricator_feed.feed_storyreference ( + objectPHID varchar(64) BINARY NOT NULL, + chronologicalKey BIGINT UNSIGNED NOT NULL, + UNIQUE KEY (objectPHID, chronologicalKey), + KEY (chronologicalKey) +); diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index f2019e64c1..4ce8f7c0ef 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -103,6 +103,7 @@ phutil_register_library_map(array( 'ConduitAPI_differential_updateunitresults_Method' => 'applications/conduit/method/differential/updateunitresults', 'ConduitAPI_diffusion_getcommits_Method' => 'applications/conduit/method/diffusion/getcommits', 'ConduitAPI_diffusion_getrecentcommitsbypath_Method' => 'applications/conduit/method/diffusion/getrecentcommitsbypath', + 'ConduitAPI_feed_publish_Method' => 'applications/conduit/method/feed/publish', 'ConduitAPI_file_download_Method' => 'applications/conduit/method/file/download', 'ConduitAPI_file_upload_Method' => 'applications/conduit/method/file/upload', 'ConduitAPI_maniphest_info_Method' => 'applications/conduit/method/maniphest/info', @@ -336,6 +337,17 @@ phutil_register_library_map(array( 'PhabricatorEmailLoginController' => 'applications/auth/controller/email', 'PhabricatorEmailTokenController' => 'applications/auth/controller/emailtoken', 'PhabricatorEnv' => 'infrastructure/env', + 'PhabricatorFeedController' => 'applications/feed/controller/base', + 'PhabricatorFeedDAO' => 'applications/feed/storage/base', + 'PhabricatorFeedQuery' => 'applications/feed/query', + 'PhabricatorFeedStory' => 'applications/feed/story/base', + 'PhabricatorFeedStoryData' => 'applications/feed/storage/story', + 'PhabricatorFeedStoryPublisher' => 'applications/feed/publisher', + 'PhabricatorFeedStoryReference' => 'applications/feed/storage/storyreference', + 'PhabricatorFeedStoryUnknown' => 'applications/feed/story/unknown', + 'PhabricatorFeedStoryView' => 'applications/feed/view/story', + 'PhabricatorFeedStreamController' => 'applications/feed/controller/stream', + 'PhabricatorFeedView' => 'applications/feed/view/base', 'PhabricatorFile' => 'applications/files/storage/file', 'PhabricatorFileController' => 'applications/files/controller/base', 'PhabricatorFileDAO' => 'applications/files/storage/base', @@ -662,6 +674,7 @@ phutil_register_library_map(array( 'ConduitAPI_differential_updateunitresults_Method' => 'ConduitAPIMethod', 'ConduitAPI_diffusion_getcommits_Method' => 'ConduitAPIMethod', 'ConduitAPI_diffusion_getrecentcommitsbypath_Method' => 'ConduitAPIMethod', + 'ConduitAPI_feed_publish_Method' => 'ConduitAPIMethod', 'ConduitAPI_file_download_Method' => 'ConduitAPIMethod', 'ConduitAPI_file_upload_Method' => 'ConduitAPIMethod', 'ConduitAPI_maniphest_info_Method' => 'ConduitAPIMethod', @@ -831,6 +844,14 @@ phutil_register_library_map(array( 'PhabricatorEditPreferencesController' => 'PhabricatorPreferencesController', 'PhabricatorEmailLoginController' => 'PhabricatorAuthController', 'PhabricatorEmailTokenController' => 'PhabricatorAuthController', + 'PhabricatorFeedController' => 'PhabricatorController', + 'PhabricatorFeedDAO' => 'PhabricatorLiskDAO', + 'PhabricatorFeedStoryData' => 'PhabricatorFeedDAO', + 'PhabricatorFeedStoryReference' => 'PhabricatorFeedDAO', + 'PhabricatorFeedStoryUnknown' => 'PhabricatorFeedStory', + 'PhabricatorFeedStoryView' => 'PhabricatorFeedView', + 'PhabricatorFeedStreamController' => 'PhabricatorFeedController', + 'PhabricatorFeedView' => 'AphrontView', 'PhabricatorFile' => 'PhabricatorFileDAO', 'PhabricatorFileController' => 'PhabricatorController', 'PhabricatorFileDAO' => 'PhabricatorLiskDAO', diff --git a/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php b/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php index a76b31a783..5670f85abd 100644 --- a/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php +++ b/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php @@ -330,6 +330,10 @@ class AphrontDefaultApplicationConfiguration 'delete/(?P\d+)/$' => 'PhabricatorCountdownDeleteController' ), + + '/feed/' => array( + '$' => 'PhabricatorFeedStreamController', + ), ); } diff --git a/src/applications/conduit/method/feed/publish/ConduitAPI_feed_publish_Method.php b/src/applications/conduit/method/feed/publish/ConduitAPI_feed_publish_Method.php new file mode 100644 index 0000000000..832350646e --- /dev/null +++ b/src/applications/conduit/method/feed/publish/ConduitAPI_feed_publish_Method.php @@ -0,0 +1,65 @@ + 'required string', + 'data' => 'required dict', + 'time' => 'optional int', + ); + } + + public function defineErrorTypes() { + return array( + ); + } + + public function defineReturnType() { + return 'nonempty phid'; + } + + protected function execute(ConduitAPIRequest $request) { + $type = $request->getValue('type'); + $data = $request->getValue('data'); + $time = $request->getValue('time'); + + $author_phid = $request->getUser()->getPHID(); + $phids = array($author_phid); + + $publisher = new PhabricatorFeedStoryPublisher(); + $publisher->setStoryType($type); + $publisher->setStoryData($data); + $publisher->setStoryTime($time); + $publisher->setRelatedPHIDs($phids); + $publisher->setStoryAuthorPHID($author_phid); + + $data = $publisher->publish(); + + return $data->getPHID(); + } + +} diff --git a/src/applications/conduit/method/feed/publish/__init__.php b/src/applications/conduit/method/feed/publish/__init__.php new file mode 100644 index 0000000000..06f341680e --- /dev/null +++ b/src/applications/conduit/method/feed/publish/__init__.php @@ -0,0 +1,13 @@ +buildStandardPageView(); + + $page->setApplicationName('Feed'); + $page->setBaseURI('/feed/'); + $page->setTitle(idx($data, 'title')); + $page->setGlyph("\xE2\x88\x9E"); + $page->appendChild($view); + + $response = new AphrontWebpageResponse(); + return $response->setContent($page->render()); + } + +} diff --git a/src/applications/feed/controller/base/__init__.php b/src/applications/feed/controller/base/__init__.php new file mode 100644 index 0000000000..3ff17d1fb1 --- /dev/null +++ b/src/applications/feed/controller/base/__init__.php @@ -0,0 +1,15 @@ +execute(); + + $views = array(); + foreach ($stories as $story) { + $views[] = $story->renderView(); + } + + return $this->buildStandardPageResponse( + $views, + array( + 'title' => 'Feed', + )); + } +} diff --git a/src/applications/feed/controller/stream/__init__.php b/src/applications/feed/controller/stream/__init__.php new file mode 100644 index 0000000000..0005773ff0 --- /dev/null +++ b/src/applications/feed/controller/stream/__init__.php @@ -0,0 +1,13 @@ +relatedPHIDs = $phids; + return $this; + } + + public function setStoryType($story_type) { + $this->storyType = $story_type; + return $this; + } + + public function setStoryData(array $data) { + $this->storyData = $data; + return $this; + } + + public function setStoryTime($time) { + $this->storyTime = $time; + return $this; + } + + public function setStoryAuthorPHID($phid) { + $this->storyAuthorPHID = $phid; + return $this; + } + + public function publish() { + if (!$this->relatedPHIDs) { + throw new Exception("There are no PHIDs related to this story!"); + } + + if (!$this->storyType) { + throw new Exception("Call setStoryType() before publishing!"); + } + + $chrono_key = $this->generateChronologicalKey(); + + $story = new PhabricatorFeedStoryData(); + $story->setStoryType($this->storyType); + $story->setStoryData($this->storyData); + $story->setAuthorPHID($this->storyAuthorPHID); + $story->setChronologicalKey($chrono_key); + $story->save(); + + $ref = new PhabricatorFeedStoryReference(); + + $sql = array(); + $conn = $ref->establishConnection('w'); + foreach ($this->relatedPHIDs as $phid) { + $sql[] = qsprintf( + $conn, + '(%s, %s)', + $phid, + $chrono_key); + } + + queryfx( + $conn, + 'INSERT INTO %T (objectPHID, chronologicalKey) VALUES %Q', + $ref->getTableName(), + implode(', ', $sql)); + + return $story; + } + + /** + * We generate a unique chronological key for each story type because we want + * to be able to page through the stream with a cursor (i.e., select stories + * after ID = X) so we can efficiently perform filtering after selecting data, + * and multiple stories with the same ID make this cumbersome without putting + * a bunch of logic in the client. We could use the primary key, but that + * would prevent publishing stories which happened in the past. Since it's + * potentially useful to do that (e.g., if you're importing another data + * source) build a unique key for each story which has chronological ordering. + * + * @return string A unique, time-ordered key which identifies the story. + */ + private function generateChronologicalKey() { + // Use the epoch timestamp for the upper 32 bits of the key. Default to + // the current time if the story doesn't have an explicit timestamp. + $time = nonempty($this->storyTime, time()); + + // Generate a random number for the lower 32 bits of the key. + $rand = head(unpack('L', Filesystem::readRandomBytes(4))); + + return ($time << 32) + ($rand); + } +} diff --git a/src/applications/feed/publisher/__init__.php b/src/applications/feed/publisher/__init__.php new file mode 100644 index 0000000000..ccdcf4029d --- /dev/null +++ b/src/applications/feed/publisher/__init__.php @@ -0,0 +1,18 @@ +filterPHIDs = $phids; + return $this; + } + + public function setLimit($limit) { + $this->limit = $limit; + return $this; + } + + public function setAfter($after) { + $this->after = $after; + return $this; + } + + public function execute() { + + $ref_table = new PhabricatorFeedStoryReference(); + $story_table = new PhabricatorFeedStoryData(); + + $conn = $story_table->establishConnection('r'); + + $where = array(); + if ($this->filterPHIDs) { + $where[] = qsprintf( + $conn, + 'ref.objectPHID IN (%Ls)', + $this->filterPHIDs); + } + + if ($where) { + $where = 'WHERE ('.implode(') AND (', $where).')'; + } else { + $where = ''; + } + + $data = queryfx_all( + $conn, + 'SELECT story.* FROM %T ref + JOIN %T story ON ref.chronologicalKey = story.chronologicalKey + %Q + GROUP BY story.chronologicalKey + ORDER BY story.chronologicalKey DESC + LIMIT %d', + $ref_table->getTableName(), + $story_table->getTableName(), + $where, + $this->limit); + $data = $story_table->loadAllFromArray($data); + + $stories = array(); + foreach ($data as $story_data) { + $class = $story_data->getStoryType(); + + try { + if (!class_exists($class) || + !is_subclass_of($class, 'PhabricatorFeedStory')) { + $class = 'PhabricatorFeedStoryUnknown'; + } + } catch (PhutilMissingSymbolException $ex) { + // If the class can't be loaded, libphutil will throw an exception. + // Render the story using the unknown story view. + $class = 'PhabricatorFeedStoryUnknown'; + } + + $stories[] = newv($class, array($story_data)); + } + + return $stories; + } +} diff --git a/src/applications/feed/query/__init__.php b/src/applications/feed/query/__init__.php new file mode 100644 index 0000000000..4d574245d8 --- /dev/null +++ b/src/applications/feed/query/__init__.php @@ -0,0 +1,17 @@ + true, + self::CONFIG_SERIALIZATION => array( + 'storyData' => self::SERIALIZATION_JSON, + ), + ) + parent::getConfiguration(); + } + + public function generatePHID() { + return PhabricatorPHID::generateNewPHID( + PhabricatorPHIDConstants::PHID_TYPE_STRY); + } + +} diff --git a/src/applications/feed/storage/story/__init__.php b/src/applications/feed/storage/story/__init__.php new file mode 100644 index 0000000000..9da164a706 --- /dev/null +++ b/src/applications/feed/storage/story/__init__.php @@ -0,0 +1,14 @@ + self::IDS_MANUAL, + self::CONFIG_TIMESTAMPS => false, + ) + parent::getConfiguration(); + } + +} diff --git a/src/applications/feed/storage/storyreference/__init__.php b/src/applications/feed/storage/storyreference/__init__.php new file mode 100644 index 0000000000..a0921444f3 --- /dev/null +++ b/src/applications/feed/storage/storyreference/__init__.php @@ -0,0 +1,12 @@ +data = $data; + } + + abstract public function renderView(); + +} diff --git a/src/applications/feed/story/base/__init__.php b/src/applications/feed/story/base/__init__.php new file mode 100644 index 0000000000..96e61a9589 --- /dev/null +++ b/src/applications/feed/story/base/__init__.php @@ -0,0 +1,10 @@ + 'border: 1px dashed black; '. + 'padding: 1em; margin: 1em; '. + 'background: #ffeedd;', + ), + 'This is a feed story!'); + } + +} diff --git a/src/applications/feed/view/story/__init__.php b/src/applications/feed/view/story/__init__.php new file mode 100644 index 0000000000..df6094318e --- /dev/null +++ b/src/applications/feed/view/story/__init__.php @@ -0,0 +1,14 @@ +