diff --git a/css/main.css b/css/main.css
index ea78566..0899ebe 100644
--- a/css/main.css
+++ b/css/main.css
@@ -287,6 +287,30 @@ body, html {
background-color: #6c6;
}
+#channels ul.groupMembers
+{
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+#channels ul.groupMembers li > a > img
+{
+ width: 50px;
+ height: 50px;
+}
+
+#channels ul.groupMembers li
+{
+ float: left;
+ padding-right: 0.5rem;
+}
+
+#channels .clearingDiv
+{
+ clear: both;
+}
+
a.blockedAvatar
{
display: inline-block;
diff --git a/css/profile.css b/css/profile.css
index 2296a6e..5e3dce4 100644
--- a/css/profile.css
+++ b/css/profile.css
@@ -55,6 +55,7 @@
margin: 16px 0px 0px 0px;
}
+#profile .profileButtons span.addToGroup div.v-select,
#profile .profileButtons button.clickButton {
margin-left: 1rem;
margin-top: 16px;
@@ -112,3 +113,13 @@
width: 100%;
height: 10rem;
}
+
+#profile span.addToGroup {
+ display: inline-block;
+ float: right;
+}
+
+#profile span.addToGroup div.v-select {
+ display: inline-block;
+ width: 20em;
+}
diff --git a/localprefs.js b/localprefs.js
index d4693b2..9005073 100644
--- a/localprefs.js
+++ b/localprefs.js
@@ -51,6 +51,10 @@ exports.getAutorefresh = function() { return (getPref('autorefresh', (typeof def
exports.setAutorefresh = function(isOn) { setPref('autorefresh', (isOn ? 'true' : 'false')) }
+exports.getUserGroups = function() { return JSON.parse(getPref('userGroups', JSON.stringify(defaultPrefs.userGroups || []))) }
+
+exports.setUserGroups = function(userGroupArray) { setPref('userGroups', JSON.stringify(userGroupArray)) }
+
exports.updateStateFromSettings = function() {
// Update the running state to match the stored settings.
SSB.hops = this.getHops()
diff --git a/messages.json b/messages.json
index fe14666..4ed95ca 100644
--- a/messages.json
+++ b/messages.json
@@ -43,6 +43,10 @@
},
"channels": {
"title": "Channels",
+ "yourGroups": "Your groups",
+ "newGroupName": "New group name",
+ "addGroup": "Add group",
+ "confirmDeleteGroup": "Are you sure you want to delete the group \"{group}\"?",
"favoriteChannels": "Favorite channels",
"otherChannels": "Other channels",
"showChannels": "Show:",
@@ -66,6 +70,11 @@
"dbStatus": "DB status",
"ebtStatus": "EBT status"
},
+ "group": {
+ "title": "Group \"{name}\"",
+ "blankFieldError": "Please provide a message to publish.",
+ "postNewMessage": "Post general public message"
+ },
"messagePreview": {
"postPreview": "Post preview",
"postMessage": "Post message"
@@ -102,6 +111,10 @@
"saveProfile": "Save profile",
"sendMessage": "Send message",
"removeFeed": "Remove feed",
+ "groupDropdownPlaceholder": "Add to group",
+ "addToGroup": "Add to group",
+ "removeFromGroup": "Remove from group",
+ "chooseGroupFirst": "Please choose a group first",
"following": "Following",
"blocking": "Blocking",
"lastXMessagesFor": "Last {count} messages for",
@@ -287,6 +300,10 @@
},
"channels": {
"title": "チャンネル",
+ "yourGroups": "あなたのグループ",
+ "newGroupName": "新しいグループ名",
+ "addGroup": "グループを追加",
+ "confirmDeleteGroup": "「{group}」という名前のグループを削除してもよろしいですか?",
"favoriteChannels": "お気に入りのチャンネル",
"otherChannels": "他のチャネル",
"showChannels": "見るに:",
@@ -310,6 +327,11 @@
"dbStatus": "データベースのステータス",
"ebtStatus": "EBTのステータス"
},
+ "group": {
+ "title": "グループ「{name}」",
+ "blankFieldError": "公開するメッセージを入力してください。",
+ "postNewMessage": "一般公開メッセージを投稿する"
+ },
"messagePreview": {
"postPreview": "プレビュー",
"postMessage": "掲示します"
@@ -346,6 +368,10 @@
"saveProfile": "プロファイルを保存",
"sendMessage": "メッセージを送る",
"removeFeed": "フィードを削除する",
+ "groupDropdownPlaceholder": "グループに追加",
+ "addToGroup": "グループに追加",
+ "removeFromGroup": "グループから削除",
+ "chooseGroupFirst": "最初にグループを選択してください",
"following": "フォローする",
"blocking": "ブロックする",
"lastXMessagesFor": "最後の{count}つのメッセージ:",
diff --git a/ui/browser.js b/ui/browser.js
index f322fed..925d547 100644
--- a/ui/browser.js
+++ b/ui/browser.js
@@ -40,6 +40,7 @@ require('ssb-browser-core/core').init("/.ssb-lite", optionsForCore);
const Profile = require('./profile')()
const Notifications = require('./notifications')()
const Channel = require('./channel')()
+ const Group = require('./group')()
const Channels = require('./channels')()
const Thread = require('./thread')()
const Private = require('./private')(componentsState)
@@ -54,6 +55,7 @@ require('ssb-browser-core/core').init("/.ssb-lite", optionsForCore);
{ name: 'public', path: '/public', component: Public },
{ name: 'channels', path: '/channels', component: Channels },
{ name: 'channel', path: '/channel/:channel', component: Channel, props: true },
+ { name: 'group', path: '/group/:group', component: Group, props: true },
{ name: 'thread', path: '/thread/:rootId', component: Thread, props: true },
{ name: 'profile', path: '/profile/:feedId', component: Profile, props: true },
{ name: 'notifications', path: '/notifications', component: Notifications },
diff --git a/ui/channels.js b/ui/channels.js
index 1b7bc36..6012b44 100644
--- a/ui/channels.js
+++ b/ui/channels.js
@@ -1,11 +1,25 @@
module.exports = function (componentsState) {
const pull = require('pull-stream')
const localPrefs = require('../localprefs')
+ const userGroups = require('../usergroups')
const { and, not, isPublic, type, channel, startFrom, paginate, descending, toCallback } = SSB.dbOperators
return {
template: `
+
{{ $t('channels.yourGroups') }}
+
+
+
{{ group.name }} X
+
+
+
+
+
{{ $t('channels.title') }}
{{ $t('channels.favoriteChannels') }}
@@ -24,7 +38,7 @@ module.exports = function (componentsState) {
-
#{{ channel }}[ {{ count }} ]
-
+
{{ $t('channels.loading') }}
`,
@@ -32,18 +46,89 @@ module.exports = function (componentsState) {
data: function() {
return {
channels: [],
- favoriteChannels: [],
+ favoriteChannels: [],
+ groups: [],
+ groupName: '',
sortMode: "recent"
}
},
methods: {
+ groupMemberInfoCallback: function(err, groupId, members) {
+ for (g in this.groups) {
+ if (this.groups[g].id == groupId) {
+ this.groups[g].members = members
+ return
+ }
+ }
+ },
+
+ loadGroups: function() {
+ var self = this
+ userGroups.getGroups((err, groups) => {
+ for (g in groups) {
+ var found = false
+ var fetchMembers = true
+ for (l in self.groups) {
+ if (groups[g].id == self.groups[l].id) {
+ found = true
+ if (self.groups[l].members && self.groups[l].members.length > 0) {
+ // Already have member info.
+ groups[g].members = self.groups[l].members
+ fetchMembers = false
+ }
+ }
+ }
+
+ if (!found) {
+ groups[g].members = []
+ self.groups.push(groups[g])
+ }
+ if (fetchMembers) {
+ userGroups.getMembers(groups[g].id, self.groupMemberInfoCallback)
+ }
+ }
+
+ // Normally I'd like to do this with a temporary variable and storing to the data variable once at the end, but in this case we've got too many potential member callbacks - complexity would be too high.
+ const sortFunc = (new Intl.Collator()).compare
+ self.groups = self.groups.sort((a, b) => { return sortFunc(a.name, b.name) })
+ })
+ },
+
+ addGroup: function() {
+ var self = this
+ userGroups.addGroup({ name: this.groupName }, (err, groupId) => {
+ if (err) {
+ alert(err)
+ } else {
+ self.loadGroups()
+ self.groupName = ''
+ }
+ })
+ },
+
+ deleteGroup: function(group) {
+ var self = this
+ if (confirm(this.$root.$t('channels.confirmDeleteGroup', { group: group.name }))) {
+ userGroups.deleteGroup(group.id, (err, success) => {
+ if (err)
+ alert(err)
+ else {
+ self.groups = []
+ self.loadGroups()
+ }
+ })
+ }
+ },
+
load: function() {
document.body.classList.add('refreshing')
// Get favorite channels from preferences.
this.favoriteChannels = localPrefs.getFavoriteChannels().map(x => x.replace(/^#+/, '')).sort(Intl.Collator().compare)
+ this.loadGroups()
+
console.time("channel list")
const resultCallback = toCallback((err, answer) => {
diff --git a/ui/group.js b/ui/group.js
new file mode 100644
index 0000000..c40fec5
--- /dev/null
+++ b/ui/group.js
@@ -0,0 +1,151 @@
+module.exports = function () {
+ const pull = require('pull-stream')
+ const ssbMentions = require('ssb-mentions')
+ const { and, or, author, isPublic, type, descending, startFrom, paginate, toCallback } = SSB.dbOperators
+ const userGroups = require('../usergroups')
+
+ return {
+ template: `
+
+
{{ $t('group.title', { name: groupName }) }}
+
+
+
+
+
{{ $t('common.lastXMessages', { count: pageSize }) }}
+ ↻
+
+
+
+
{{ $t('common.noMessages') }}
+
{{ $t('common.showingMessagesFrom') }} 1-{{ displayPageEnd }}
+
+
+
+
`,
+
+ props: ['group'],
+
+ data: function() {
+ return {
+ groupName: "Loading...",
+ groupMembers: [],
+ postMessageVisible: false,
+ postText: "",
+ offset: 0,
+ pageSize: 50,
+ displayPageEnd: 50,
+ autorefreshTimer: 0,
+ showPreview: false,
+ messages: []
+ }
+ },
+
+ methods: {
+ loadMore: function() {
+ SSB.db.query(
+ and(or(...this.groupMembers.map(x => author(x))), isPublic(), type('post')),
+ descending(),
+ startFrom(this.offset),
+ paginate(this.pageSize),
+ toCallback((err, answer) => {
+ this.messages = this.messages.concat(answer.results)
+ this.displayPageEnd = this.offset + this.pageSize
+ this.offset += this.pageSize // If we go by result length and we have filtered out all messages, we can never get more.
+ })
+ )
+ },
+
+ render: function () {
+ this.loadMore()
+ },
+
+ onFileSelect: function(ev) {
+ var self = this
+ helpers.handleFileSelect(ev, false, (err, text) => {
+ self.postText += text
+ })
+ },
+
+ closePreview: function() {
+ this.showPreview = false
+ },
+
+ onPost: function() {
+ if (!this.postMessageVisible) {
+ this.postMessageVisible = true
+ return
+ }
+
+ this.postText = this.$refs.markdownEditor.getMarkdown()
+
+ // Make sure the full post (including headers) is not larger than the 8KiB limit.
+ var postData = this.buildPostData()
+ if (JSON.stringify(postData).length > 8192) {
+ alert(this.$root.$t('common.postTooLarge'))
+ return
+ }
+
+ if (this.postText == '') {
+ alert(this.$root.$t('channel.blankFieldError'))
+ return
+ }
+
+ this.showPreview = true
+ },
+
+ buildPostData: function() {
+ var mentions = ssbMentions(this.postText)
+
+ var postData = { type: 'post', text: this.postText, mentions: mentions }
+
+ return postData
+ },
+
+ confirmPost: function() {
+ var self = this
+
+ var postData = this.buildPostData()
+
+ SSB.db.publish(postData, (err) => {
+ if (err) console.log(err)
+
+ self.postText = ""
+ self.postMessageVisible = false
+ self.showPreview = false
+
+ self.refresh()
+ })
+ },
+
+ refresh: function() {
+ console.log("Refreshing")
+ this.messages = []
+ this.offset = 0
+ this.render()
+ }
+ },
+
+ created: function () {
+ document.title = this.$root.appTitle + " - " + this.$root.$t('group.title', { name: this.groupName })
+
+ var self = this
+ userGroups.getGroups((err, groups) => {
+ for (g in groups) {
+ if (groups[g].id == self.group)
+ self.groupName = groups[g].name
+ }
+ })
+ userGroups.getMembers(self.group, (err, groupId, members) => {
+ self.groupMembers = members
+ this.render()
+ })
+ },
+
+ watch: {
+ groupName: function(oldVal, newVal) {
+ document.title = this.$root.appTitle + " - " + this.$root.$t('group.title', { name: this.groupName })
+ }
+ }
+ }
+}
diff --git a/ui/profile.js b/ui/profile.js
index 1ac648e..10d95e8 100644
--- a/ui/profile.js
+++ b/ui/profile.js
@@ -2,6 +2,7 @@ module.exports = function () {
const pull = require('pull-stream')
const helpers = require('./helpers')
const md = require('./markdown')
+ const userGroups = require('../usergroups')
const { and, author, type, isPublic, startFrom, paginate, descending, toCallback } = SSB.dbOperators
let initialState = function(self) {
@@ -23,6 +24,10 @@ module.exports = function () {
showImportKey: false,
mnemonic: '',
+ group: '',
+ alreadyInGroup: false,
+ groups: [],
+
offset: 0
}
}
@@ -61,7 +66,12 @@ module.exports = function () {
{{ $t('profile.sendMessage') }}
-
+
+
+
+
+
+
{{ $t('profile.following') }}
@@ -153,6 +163,47 @@ module.exports = function () {
},
methods: {
+ groupChange: function() {
+ var self = this
+ if (this.group && this.group.id && this.group.id != '') {
+ userGroups.getMembers(this.group.id, (err, groupId, members) => {
+ self.alreadyInGroup = (members.indexOf(this.feedId) >= 0)
+ })
+ }
+ },
+
+ addToGroup: function() {
+ var self = this
+ if (!this.group || !this.group.id || this.group.id == '') {
+ alert(this.$root.$t('profile.chooseGroupFirst'))
+ return
+ }
+ userGroups.addMember(this.group.id, this.feedId, (err, success) => {
+ if (err) {
+ alert(err)
+ return
+ }
+
+ self.alreadyInGroup = true
+ })
+ },
+
+ removeFromGroup: function() {
+ var self = this
+ if (!this.group || !this.group.id || this.group.id == '') {
+ alert(this.$root.$t('profile.chooseGroupFirst'))
+ return
+ }
+ userGroups.removeMember(this.group.id, this.feedId, (err, success) => {
+ if (err) {
+ alert(err)
+ return
+ }
+
+ self.alreadyInGroup = false
+ })
+ },
+
cacheImageURLForPreview: function(blobId, cb) {
var self = this
++this.waitingForBlobURLs
@@ -397,6 +448,13 @@ module.exports = function () {
self.blocking = self.feedId != SSB.net.id && contacts.isBlocking(SSB.net.id, self.feedId)
})
+ userGroups.getGroups((err, groups) => {
+ if (groups) {
+ const sortFunc = (new Intl.Collator()).compare
+ self.groups = groups.sort((a, b) => { return sortFunc(a.name, b.name) })
+ }
+ })
+
document.body.classList.add('refreshing')
console.time("latest 25 profile messages")
diff --git a/usergroups.js b/usergroups.js
new file mode 100644
index 0000000..c48ffd0
--- /dev/null
+++ b/usergroups.js
@@ -0,0 +1,106 @@
+const localPrefs = require("./localprefs")
+
+// Groups are stored as:
+// {
+// id: (String, with the format not to be relied upon outside of this module)
+// name: 'Name of group',
+// description: 'Description of group in Markdown format (optional)',
+// members: [
+// '@id_of_a_user',
+// '@id_of_another_user'
+// ]
+// }
+
+function getFullLocalGroupInfo() {
+ return localPrefs.getUserGroups()
+}
+
+function setFullLocalGroupInfo(groups) {
+ localPrefs.setUserGroups(groups)
+}
+
+// Gets the names and IDs of groups, but not their members, in case that becomes a more time-consuming search later.
+// This is a little roundabout for the way it's implemented right now, but I expect it will yield performance benefits if/when we persist this to SSB.
+exports.getGroups = function(cb) {
+ const groups = getFullLocalGroupInfo().map((x) => { return { name: x.name, id: x.id, description: x.description || '' } })
+
+ cb(null, groups)
+}
+
+exports.addGroup = function(groupInfo, cb) {
+ if (!groupInfo.name || groupInfo.name.trim() == '') {
+ cb("Group must have a name")
+ }
+
+ // Good enough for now, until we find a way to persist this.
+ // This is purely to make sure the API references things by ID instead of by name, so we can swap out the implementation later.
+ var id = (new Date()).getTime()
+
+ var groups = getFullLocalGroupInfo()
+ var newGroup = { name: groupInfo.name, id: id, members: [] }
+ if (groupInfo.description)
+ newGroup.description = groupInfo.description
+ groups.push(newGroup)
+ setFullLocalGroupInfo(groups)
+
+ cb(null, id)
+}
+
+exports.updateGroup = function(groupId, groupInfo, cb) {
+ cb("Not supported yet")
+}
+
+exports.deleteGroup = function(groupId, cb) {
+ var groups = getFullLocalGroupInfo()
+ groups = groups.filter((x) => { return x.id != groupId })
+ setFullLocalGroupInfo(groups)
+ cb(null, true)
+}
+
+exports.getMembers = function(groupId, cb) {
+ var groups = getFullLocalGroupInfo()
+ for (g in groups)
+ if (groups[g].id == groupId) {
+ cb(null, groupId, groups[g].members)
+ return
+ }
+
+ cb("Group not found")
+}
+
+exports.addMember = function(groupId, feedId, cb) {
+ var groups = getFullLocalGroupInfo()
+ for (g in groups)
+ if (groups[g].id == groupId) {
+ if (groups[g].members.indexOf(feedId) >= 0) {
+ cb("Group already has member")
+ return
+ } else {
+ groups[g].members.push(feedId)
+ setFullLocalGroupInfo(groups)
+ cb(null, true)
+ return
+ }
+ }
+
+ cb("Group not found")
+}
+
+exports.removeMember = function(groupId, feedId, cb) {
+ var groups = getFullLocalGroupInfo()
+ for (g in groups)
+ if (groups[g].id == groupId) {
+ if (groups[g].members.indexOf(feedId) < 0) {
+ cb("Group does not have member")
+ return
+ } else {
+ groups[g].members = groups[g].members.filter((x) => { return x != feedId })
+ setFullLocalGroupInfo(groups)
+ cb(null, true)
+ return
+ }
+ }
+
+ cb("Group not found")
+}
+