diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/conversation/conversation.js | 9 | ||||
| -rw-r--r-- | src/components/status/status.js | 3 | ||||
| -rw-r--r-- | src/components/timeline/timeline.js | 17 | ||||
| -rw-r--r-- | src/components/user_card_content/user_card_content.vue | 16 | ||||
| -rw-r--r-- | src/components/user_profile/user_profile.js | 12 | ||||
| -rw-r--r-- | src/components/user_profile/user_profile.vue | 3 | ||||
| -rw-r--r-- | src/i18n/ko.json | 370 | ||||
| -rw-r--r-- | src/i18n/messages.js | 1 | ||||
| -rw-r--r-- | src/modules/api.js | 10 | ||||
| -rw-r--r-- | src/modules/statuses.js | 127 | ||||
| -rw-r--r-- | src/modules/users.js | 12 | ||||
| -rw-r--r-- | src/services/api/api.service.js | 33 | ||||
| -rw-r--r-- | src/services/entity_normalizer/entity_normalizer.service.js | 212 | ||||
| -rw-r--r-- | src/services/timeline_fetcher/timeline_fetcher.service.js | 6 |
14 files changed, 699 insertions, 132 deletions
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 95432248..237de7e5 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -1,9 +1,8 @@ import { reduce, filter, sortBy } from 'lodash' -import { statusType } from '../../modules/statuses.js' import Status from '../status/status.vue' const sortAndFilterConversation = (conversation) => { - conversation = filter(conversation, (status) => statusType(status) !== 'retweet') + conversation = filter(conversation, (status) => status.type !== 'retweet') return sortBy(conversation, 'id') } @@ -18,10 +17,12 @@ const conversation = { 'collapsable' ], computed: { - status () { return this.statusoid }, + status () { + return this.statusoid + }, conversation () { if (!this.status) { - return false + return [] } const conversationId = this.status.statusnet_conversation_id diff --git a/src/components/status/status.js b/src/components/status/status.js index 7e1e7dab..b648ab70 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -270,7 +270,7 @@ const Status = { }, replyEnter (id, event) { this.showPreview = true - const targetId = String(id) + const targetId = id const statuses = this.$store.state.statuses.allStatuses if (!this.preview) { @@ -295,7 +295,6 @@ const Status = { }, watch: { 'highlight': function (id) { - id = String(id) if (this.status.id === id) { let rect = this.$el.getBoundingClientRect() if (rect.top < 100) { diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 98da8660..23d2c1e8 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -7,7 +7,6 @@ import { throttle } from 'lodash' const Timeline = { props: [ 'timeline', - 'timelineName', 'title', 'userId', 'tag', @@ -55,7 +54,7 @@ const Timeline = { timelineFetcher.fetchAndUpdate({ store, credentials, - timeline: this.timelineName, + timeline: this.timeline, showImmediately, userId: this.userId, tag: this.tag @@ -70,32 +69,32 @@ const Timeline = { destroyed () { window.removeEventListener('scroll', this.scrollLoad) if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) - this.$store.commit('setLoading', { timeline: this.timelineName, value: false }) + this.$store.commit('setLoading', { timeline: this.timeline, value: false }) }, methods: { showNewStatuses () { if (this.timeline.flushMarker !== 0) { - this.$store.commit('clearTimeline', { timeline: this.timelineName }) - this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 }) + this.$store.commit('clearTimeline', { timeline: this.timeline }) + this.$store.commit('queueFlush', { timeline: this.timeline, id: 0 }) this.fetchOlderStatuses() } else { - this.$store.commit('showNewStatuses', { timeline: this.timelineName }) + this.$store.commit('showNewStatuses', { timeline: this.timeline }) this.paused = false } }, fetchOlderStatuses: throttle(function () { const store = this.$store const credentials = store.state.users.currentUser.credentials - store.commit('setLoading', { timeline: this.timelineName, value: true }) + store.commit('setLoading', { timeline: this.timeline, value: true }) timelineFetcher.fetchAndUpdate({ store, credentials, - timeline: this.timelineName, + timeline: this.timeline, older: true, showImmediately: true, userId: this.userId, tag: this.tag - }).then(() => store.commit('setLoading', { timeline: this.timelineName, value: false })) + }).then(() => store.commit('setLoading', { timeline: this.timeline, value: false })) }, 1000, this), scrollLoad (e) { const bodyBRect = document.body.getBoundingClientRect() diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue index 3804302b..0e820182 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card_content/user_card_content.vue @@ -142,7 +142,7 @@ border-bottom-right-radius: 0; .panel-heading { - padding: .6em 0; + padding: .5em 0; text-align: center; box-shadow: none; } @@ -226,10 +226,11 @@ } } - .user-name{ + .user-name { text-overflow: ellipsis; overflow: hidden; - flex: 1 0 auto; + flex: 1 1 auto; + margin-right: 1em; } .user-screen-name { @@ -245,6 +246,10 @@ .dailyAvg { min-width: 1px; flex: 0 0 auto; + margin-left: 1em; + font-size: 0.7em; + color: $fallback--text; + color: var(--text, $fallback--text); } .handle { @@ -381,11 +386,6 @@ } } -.dailyAvg { - margin-left: 1em; - font-size: 0.7em; - color: #CCC; -} .floater { } </style> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 2ca09817..245d55ca 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -1,6 +1,7 @@ import UserCardContent from '../user_card_content/user_card_content.vue' import UserCard from '../user_card/user_card.vue' import Timeline from '../timeline/timeline.vue' +import { emptyTl } from '../../modules/statuses.js' const UserProfile = { created () { @@ -13,6 +14,11 @@ const UserProfile = { destroyed () { this.$store.dispatch('stopFetching', 'user') }, + data () { + return { + favorites: emptyTl({ type: 'favorites', userId: this.userId }) + } + }, computed: { timeline () { return this.$store.state.statuses.timelines.user @@ -21,7 +27,7 @@ const UserProfile = { return this.$route.params.id || this.user.id }, userName () { - return this.$route.params.name + return this.$route.params.name || this.user.screen_name }, friends () { return this.user.friends @@ -68,7 +74,7 @@ const UserProfile = { } this.$store.dispatch('stopFetching', 'user') this.$store.commit('clearTimeline', { timeline: 'user' }) - this.$store.dispatch('startFetching', ['user', this.userName]) + this.$store.dispatch('startFetching', ['user', this.fetchBy]) }, userId () { if (!this.isExternal) { @@ -76,7 +82,7 @@ const UserProfile = { } this.$store.dispatch('stopFetching', 'user') this.$store.commit('clearTimeline', { timeline: 'user' }) - this.$store.dispatch('startFetching', ['user', this.userId]) + this.$store.dispatch('startFetching', ['user', this.fetchBy]) }, user () { if (this.user.id && !this.user.followers) { diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index a46befa5..265fc65b 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -3,7 +3,7 @@ <div v-if="user.id" class="user-profile panel panel-default"> <user-card-content :user="user" :switcher="true" :selected="timeline.viewing"></user-card-content> <tab-switcher> - <Timeline :label="$t('user_card.statuses')" :embedded="true" :title="$t('user_profile.timeline_title')" :timeline="timeline" :timeline-name="'user'" :user-id="userId"/> + <Timeline :label="$t('user_card.statuses')" :embedded="true" :title="$t('user_profile.timeline_title')" :timeline="timeline" :timeline-name="'user'" :user-id="fetchBy"/> <div :label="$t('user_card.followees')"> <div v-if="friends"> <user-card v-for="friend in friends" :key="friend.id" :user="friend" :showFollows="true"></user-card> @@ -20,6 +20,7 @@ <i class="icon-spin3 animate-spin"></i> </div> </div> + <Timeline :label="$t('user_card.favorites')" :embedded="true" :title="$t('user_profile.favorites_title')" :timeline="favorites"/> </tab-switcher> </div> <div v-else class="panel user-profile-placeholder"> diff --git a/src/i18n/ko.json b/src/i18n/ko.json new file mode 100644 index 00000000..4b69df07 --- /dev/null +++ b/src/i18n/ko.json @@ -0,0 +1,370 @@ +{ + "chat": { + "title": "챗" + }, + "features_panel": { + "chat": "챗", + "gopher": "고퍼", + "media_proxy": "미디어 프록시", + "scope_options": "범위 옵션", + "text_limit": "텍스트 제한", + "title": "기능", + "who_to_follow": "팔로우 추천" + }, + "finder": { + "error_fetching_user": "사용자 정보 불러오기 실패", + "find_user": "사용자 찾기" + }, + "general": { + "apply": "적용", + "submit": "보내기" + }, + "login": { + "login": "로그인", + "description": "OAuth로 로그인", + "logout": "로그아웃", + "password": "암호", + "placeholder": "예시: lain", + "register": "가입", + "username": "사용자 이름" + }, + "nav": { + "about": "About", + "back": "뒤로", + "chat": "로컬 챗", + "friend_requests": "팔로우 요청", + "mentions": "멘션", + "dms": "다이렉트 메시지", + "public_tl": "공개 타임라인", + "timeline": "타임라인", + "twkn": "모든 알려진 네트워크", + "user_search": "사용자 검색", + "preferences": "환경설정" + }, + "notifications": { + "broken_favorite": "알 수 없는 게시물입니다, 검색 합니다...", + "favorited_you": "당신의 게시물을 즐겨찾기", + "followed_you": "당신을 팔로우", + "load_older": "오래 된 알림 불러오기", + "notifications": "알림", + "read": "읽음!", + "repeated_you": "당신의 게시물을 리핏" + }, + "post_status": { + "new_status": "새 게시물 게시", + "account_not_locked_warning": "당신의 계정은 {0} 상태가 아닙니다. 누구나 당신을 팔로우 하고 팔로워 전용 게시물을 볼 수 있습니다.", + "account_not_locked_warning_link": "잠김", + "attachments_sensitive": "첨부물을 민감함으로 설정", + "content_type": { + "plain_text": "평문" + }, + "content_warning": "주제 (필수 아님)", + "default": "LA에 도착!", + "direct_warning": "이 게시물을 멘션 된 사용자들에게만 보여집니다", + "posting": "게시", + "scope": { + "direct": "다이렉트 - 멘션 된 사용자들에게만", + "private": "팔로워 전용 - 팔로워들에게만", + "public": "공개 - 공개 타임라인으로", + "unlisted": "비공개 - 공개 타임라인에 게시 안 함" + } + }, + "registration": { + "bio": "소개", + "email": "이메일", + "fullname": "표시 되는 이름", + "password_confirm": "암호 확인", + "registration": "가입하기", + "token": "초대 토큰", + "captcha": "캡차", + "new_captcha": "이미지를 클릭해서 새로운 캡차", + "validations": { + "username_required": "공백으로 둘 수 없습니다", + "fullname_required": "공백으로 둘 수 없습니다", + "email_required": "공백으로 둘 수 없습니다", + "password_required": "공백으로 둘 수 없습니다", + "password_confirmation_required": "공백으로 둘 수 없습니다", + "password_confirmation_match": "패스워드와 일치해야 합니다" + } + }, + "settings": { + "attachmentRadius": "첨부물", + "attachments": "첨부물", + "autoload": "최하단에 도착하면 자동으로 로드 활성화", + "avatar": "아바타", + "avatarAltRadius": "아바타 (알림)", + "avatarRadius": "아바타", + "background": "배경", + "bio": "소개", + "btnRadius": "버튼", + "cBlue": "파랑 (답글, 팔로우)", + "cGreen": "초록 (리트윗)", + "cOrange": "주황 (즐겨찾기)", + "cRed": "빨강 (취소)", + "change_password": "암호 바꾸기", + "change_password_error": "암호를 바꾸는 데 몇 가지 문제가 있습니다.", + "changed_password": "암호를 바꾸었습니다!", + "collapse_subject": "주제를 가진 게시물 접기", + "composing": "작성", + "confirm_new_password": "새 패스워드 확인", + "current_avatar": "현재 아바타", + "current_password": "현재 패스워드", + "current_profile_banner": "현재 프로필 배너", + "data_import_export_tab": "데이터 불러오기 / 내보내기", + "default_vis": "기본 공개 범위", + "delete_account": "계정 삭제", + "delete_account_description": "계정과 메시지를 영구히 삭제.", + "delete_account_error": "계정을 삭제하는데 문제가 있습니다. 계속 발생한다면 인스턴스 관리자에게 문의하세요.", + "delete_account_instructions": "계정 삭제를 확인하기 위해 아래에 패스워드 입력.", + "export_theme": "프리셋 저장", + "filtering": "필터링", + "filtering_explanation": "아래의 단어를 가진 게시물들은 뮤트 됩니다, 한 줄에 하나씩 적으세요", + "follow_export": "팔로우 내보내기", + "follow_export_button": "팔로우 목록을 csv로 내보내기", + "follow_export_processing": "진행 중입니다, 곧 다운로드 가능해 질 것입니다", + "follow_import": "팔로우 불러오기", + "follow_import_error": "팔로우 불러오기 실패", + "follows_imported": "팔로우 목록을 불러왔습니다! 처리에는 시간이 걸립니다.", + "foreground": "전경", + "general": "일반", + "hide_attachments_in_convo": "대화의 첨부물 숨기기", + "hide_attachments_in_tl": "타임라인의 첨부물 숨기기", + "hide_isp": "인스턴스 전용 패널 숨기기", + "preload_images": "이미지 미리 불러오기", + "hide_post_stats": "게시물 통계 숨기기 (즐겨찾기 수 등)", + "hide_user_stats": "사용자 통계 숨기기 (팔로워 수 등)", + "import_followers_from_a_csv_file": "csv 파일에서 팔로우 목록 불러오기", + "import_theme": "프리셋 불러오기", + "inputRadius": "입력 칸", + "checkboxRadius": "체크박스", + "instance_default": "(기본: {value})", + "instance_default_simple": "(기본)", + "interface": "인터페이스", + "interfaceLanguage": "인터페이스 언어", + "invalid_theme_imported": "선택한 파일은 지원하는 플레로마 테마가 아닙니다. 아무런 변경도 일어나지 않았습니다.", + "limited_availability": "이 브라우저에서 사용 불가", + "links": "링크", + "lock_account_description": "계정을 승인 된 팔로워들로 제한", + "loop_video": "비디오 반복재생", + "loop_video_silent_only": "소리가 없는 비디오만 반복 재생 (마스토돈의 \"gifs\" 같은 것들)", + "name": "이름", + "name_bio": "이름 & 소개", + "new_password": "새 암호", + "notification_visibility": "보여 줄 알림 종류", + "notification_visibility_follows": "팔로우", + "notification_visibility_likes": "좋아함", + "notification_visibility_mentions": "멘션", + "notification_visibility_repeats": "반복", + "no_rich_text_description": "모든 게시물의 서식을 지우기", + "hide_network_description": "내 팔로우와 팔로워를 숨기기", + "nsfw_clickthrough": "NSFW 이미지 \"클릭해서 보이기\"를 활성화", + "panelRadius": "패널", + "pause_on_unfocused": "탭이 활성 상태가 아닐 때 스트리밍 멈추기", + "presets": "프리셋", + "profile_background": "프로필 배경", + "profile_banner": "프로필 배너", + "profile_tab": "프로필", + "radii_help": "인터페이스 모서리 둥글기 (픽셀 단위)", + "replies_in_timeline": "답글을 타임라인에", + "reply_link_preview": "마우스를 올려서 답글 링크 미리보기 활성화", + "reply_visibility_all": "모든 답글 보기", + "reply_visibility_following": "나에게 직접 오는 답글이나 내가 팔로우 중인 사람에게서 오는 답글만 표시", + "reply_visibility_self": "나에게 직접 전송 된 답글만 보이기", + "saving_err": "설정 저장 실패", + "saving_ok": "설정 저장 됨", + "security_tab": "보안", + "scope_copy": "답글을 달 때 공개 범위 따라가리 (다이렉트 메시지는 언제나 따라감)", + "set_new_avatar": "새 아바타 설정", + "set_new_profile_background": "새 프로필 배경 설정", + "set_new_profile_banner": "새 프로필 배너 설정", + "settings": "설정", + "subject_input_always_show": "항상 주제 칸 보이기", + "subject_line_behavior": "답글을 달 때 주제 복사하기", + "subject_line_email": "이메일처럼: \"re: 주제\"", + "subject_line_mastodon": "마스토돈처럼: 그대로 복사", + "subject_line_noop": "복사 안 함", + "stop_gifs": "GIF파일에 마우스를 올려서 재생", + "streaming": "최상단에 도달하면 자동으로 새 게시물 스트리밍", + "text": "텍스트", + "theme": "테마", + "theme_help": "16진수 색상코드(#rrggbb)를 사용해 색상 테마를 커스터마이즈.", + "theme_help_v2_1": "체크박스를 통해 몇몇 컴포넌트의 색상과 불투명도를 조절 가능, \"모두 지우기\" 버튼으로 덮어 씌운 것을 모두 취소.", + "theme_help_v2_2": "몇몇 입력칸 밑의 아이콘은 전경/배경 대비 관련 표시등입니다, 마우스를 올려 자세한 정보를 볼 수 있습니다. 투명도 대비 표시등이 가장 최악의 경우를 나타낸다는 것을 유의하세요.", + "tooltipRadius": "툴팁/경고", + "user_settings": "사용자 설정", + "values": { + "false": "아니오", + "true": "네" + }, + "notifications": "알림", + "enable_web_push_notifications": "웹 푸시 알림 활성화", + "style": { + "switcher": { + "keep_color": "색상 유지", + "keep_shadows": "그림자 유지", + "keep_opacity": "불투명도 유지", + "keep_roundness": "둥글기 유지", + "keep_fonts": "글자체 유지", + "save_load_hint": "\"유지\" 옵션들은 다른 테마를 고르거나 불러 올 때 현재 설정 된 옵션들을 건드리지 않게 합니다, 테마를 내보내기 할 때도 이 옵션에 따라 저장합니다. 아무 것도 체크 되지 않았다면 모든 설정을 내보냅니다.", + "reset": "초기화", + "clear_all": "모두 지우기", + "clear_opacity": "불투명도 지우기" + }, + "common": { + "color": "색상", + "opacity": "불투명도", + "contrast": { + "hint": "대비율이 {ratio}입니다, 이것은 {context} {level}", + "level": { + "aa": "AA등급 가이드라인에 부합합니다 (최소한도)", + "aaa": "AAA등급 가이드라인에 부합합니다 (권장)", + "bad": "아무런 가이드라인 등급에도 미치지 못합니다" + }, + "context": { + "18pt": "큰 (18pt 이상) 텍스트에 대해", + "text": "텍스트에 대해" + } + } + }, + "common_colors": { + "_tab_label": "일반", + "main": "일반 색상", + "foreground_hint": "\"고급\" 탭에서 더 자세한 설정이 가능합니다", + "rgbo": "아이콘, 강조, 배지" + }, + "advanced_colors": { + "_tab_label": "고급", + "alert": "주의 배경", + "alert_error": "에러", + "badge": "배지 배경", + "badge_notification": "알림", + "panel_header": "패널 헤더", + "top_bar": "상단 바", + "borders": "테두리", + "buttons": "버튼", + "inputs": "입력칸", + "faint_text": "흐려진 텍스트" + }, + "radii": { + "_tab_label": "둥글기" + }, + "shadows": { + "_tab_label": "그림자와 빛", + "component": "컴포넌트", + "override": "덮어쓰기", + "shadow_id": "그림자 #{value}", + "blur": "흐리기", + "spread": "퍼지기", + "inset": "안쪽으로", + "hint": "그림자에는 CSS3 변수를 --variable을 통해 색상 값으로 사용할 수 있습니다. 불투명도에는 적용 되지 않습니다.", + "filter_hint": { + "always_drop_shadow": "경고, 이 그림자는 브라우저가 지원하는 경우 항상 {0}을 사용합니다.", + "drop_shadow_syntax": "{0}는 {1} 파라미터와 {2} 키워드를 지원하지 않습니다.", + "avatar_inset": "안쪽과 안쪽이 아닌 그림자를 모두 설정하는 경우 투명 아바타에서 예상치 못 한 결과가 나올 수 있다는 것에 주의해 주세요.", + "spread_zero": "퍼지기가 0보다 큰 그림자는 0으로 설정한 것과 동일하게 보여집니다", + "inset_classic": "안쪽 그림자는 {0}를 사용합니다" + }, + "components": { + "panel": "패널", + "panelHeader": "패널 헤더", + "topBar": "상단 바", + "avatar": "사용자 아바타 (프로필 뷰에서)", + "avatarStatus": "사용자 아바타 (게시물에서)", + "popup": "팝업과 툴팁", + "button": "버튼", + "buttonHover": "버튼 (마우스 올렸을 때)", + "buttonPressed": "버튼 (눌렸을 때)", + "buttonPressedHover": "Button (마우스 올림 + 눌림)", + "input": "입력칸" + } + }, + "fonts": { + "_tab_label": "글자체", + "help": "인터페이스의 요소에 사용 될 글자체를 고르세요. \"커스텀\"은 시스템에 있는 폰트 이름을 정확히 입력해야 합니다.", + "components": { + "interface": "인터페이스", + "input": "입력칸", + "post": "게시물 텍스트", + "postCode": "게시물의 고정폭 텍스트 (서식 있는 텍스트)" + }, + "family": "글자체 이름", + "size": "크기 (px 단위)", + "weight": "굵기", + "custom": "커스텀" + }, + "preview": { + "header": "미리보기", + "content": "내용", + "error": "에러 예시", + "button": "버튼", + "text": "더 많은 {0} 그리고 {1}", + "mono": "내용", + "input": "LA에 막 도착!", + "faint_link": "도움 되는 설명서", + "fine_print": "우리의 {0} 를 읽고 도움 되지 않는 것들을 배우자!", + "header_faint": "이건 괜찮아", + "checkbox": "나는 약관을 대충 훑어보았습니다", + "link": "작고 귀여운 링크" + } + } + }, + "timeline": { + "collapse": "접기", + "conversation": "대화", + "error_fetching": "업데이트 불러오기 실패", + "load_older": "더 오래 된 게시물 불러오기", + "no_retweet_hint": "팔로워 전용, 다이렉트 메시지는 반복할 수 없습니다", + "repeated": "반복 됨", + "show_new": "새로운 것 보기", + "up_to_date": "최신 상태" + }, + "user_card": { + "approve": "승인", + "block": "차단", + "blocked": "차단 됨!", + "deny": "거부", + "follow": "팔로우", + "follow_sent": "요청 보내짐!", + "follow_progress": "요청 중…", + "follow_again": "요청을 다시 보낼까요?", + "follow_unfollow": "팔로우 중지", + "followees": "팔로우 중", + "followers": "팔로워", + "following": "팔로우 중!", + "follows_you": "당신을 팔로우 합니다!", + "its_you": "당신입니다!", + "mute": "침묵", + "muted": "침묵 됨", + "per_day": " / 하루", + "remote_follow": "원격 팔로우", + "statuses": "게시물" + }, + "user_profile": { + "timeline_title": "사용자 타임라인" + }, + "who_to_follow": { + "more": "더 보기", + "who_to_follow": "팔로우 추천" + }, + "tool_tip": { + "media_upload": "미디어 업로드", + "repeat": "반복", + "reply": "답글", + "favorite": "즐겨찾기", + "user_settings": "사용자 설정" + }, + "upload":{ + "error": { + "base": "업로드 실패.", + "file_too_big": "파일이 너무 커요 [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "잠시 후에 다시 시도해 보세요" + }, + "file_size_units": { + "B": "바이트", + "KiB": "키비바이트", + "MiB": "메비바이트", + "GiB": "기비바이트", + "TiB": "테비바이트" + } + } +} diff --git a/src/i18n/messages.js b/src/i18n/messages.js index ee08db44..7a57648e 100644 --- a/src/i18n/messages.js +++ b/src/i18n/messages.js @@ -22,6 +22,7 @@ const messages = { hu: require('./hu.json'), it: require('./it.json'), ja: require('./ja.json'), + ko: require('./ko.json'), nb: require('./nb.json'), oc: require('./oc.json'), pl: require('./pl.json'), diff --git a/src/modules/api.js b/src/modules/api.js index a61340c2..b85b24be 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -5,7 +5,7 @@ import { Socket } from 'phoenix' const api = { state: { backendInteractor: backendInteractorService(), - fetchers: {}, + fetchers: new Map(), socket: null, chatDisabled: false, followRequests: [] @@ -15,10 +15,10 @@ const api = { state.backendInteractor = backendInteractor }, addFetcher (state, {timeline, fetcher}) { - state.fetchers[timeline] = fetcher + state.fetchers.set(timeline, fetcher) }, removeFetcher (state, {timeline}) { - delete state.fetchers[timeline] + delete state.fetchers.delete(timeline) }, setSocket (state, socket) { state.socket = socket @@ -41,13 +41,13 @@ const api = { } // Don't start fetching if we already are. - if (!store.state.fetchers[timeline]) { + if (!store.state.fetchers.has(timeline)) { const fetcher = store.state.backendInteractor.startFetching({timeline, store, userId}) store.commit('addFetcher', {timeline, fetcher}) } }, stopFetching (store, timeline) { - const fetcher = store.state.fetchers[timeline] + const fetcher = store.state.fetchers.get(timeline) window.clearInterval(fetcher) store.commit('removeFetcher', {timeline}) }, diff --git a/src/modules/statuses.js b/src/modules/statuses.js index c564dec1..baeef8bf 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -1,8 +1,8 @@ -import { includes, remove, slice, each, find, maxBy, minBy, merge, last, isArray } from 'lodash' +import { remove, slice, each, find, maxBy, minBy, merge, last, isArray } from 'lodash' import apiService from '../services/api/api.service.js' // import parse from '../services/status_parser/status_parser.js' -const emptyTl = () => ({ +export const emptyTl = (tl, userId = 0) => (Object.assign(tl, { statuses: [], statusesObject: {}, faves: [], @@ -14,9 +14,9 @@ const emptyTl = () => ({ loading: false, followers: [], friends: [], - userId: 0, - flushMarker: 0 -}) + flushMarker: 0, + userId +})) export const defaultState = { allStatuses: [], @@ -33,30 +33,17 @@ export const defaultState = { favorites: new Set(), error: false, timelines: { - mentions: emptyTl(), - public: emptyTl(), - user: emptyTl(), - publicAndExternal: emptyTl(), - friends: emptyTl(), - tag: emptyTl(), - dms: emptyTl() + mentions: emptyTl({ type: 'mentions' }), + public: emptyTl({ type: 'public' }), + user: emptyTl({ type: 'user' }), // TODO: switch to unregistered + publicAndExternal: emptyTl({ type: 'publicAndExternal' }), + friends: emptyTl({ type: 'friends' }), + tag: emptyTl({ type: 'tag' }), + dms: emptyTl({ type: 'dms' }) } } -const isNsfw = (status) => { - const nsfwRegex = /#nsfw/i - return includes(status.tags, 'nsfw') || !!status.text.match(nsfwRegex) -} - export const prepareStatus = (status) => { - // Parse nsfw tags - if (status.nsfw === undefined) { - status.nsfw = isNsfw(status) - if (status.retweeted_status) { - status.nsfw = status.retweeted_status.nsfw - } - } - // Set deleted flag status.deleted = false @@ -75,31 +62,6 @@ const visibleNotificationTypes = (rootState) => { ].filter(_ => _) } -export const statusType = (status) => { - if (status.is_post_verb) { - return 'status' - } - - if (status.retweeted_status) { - return 'retweet' - } - - if ((typeof status.uri === 'string' && status.uri.match(/(fave|objectType=Favourite)/)) || - (typeof status.text === 'string' && status.text.match(/favorited/))) { - return 'favorite' - } - - if (status.text.match(/deleted notice {{tag/) || status.qvitter_delete_notice) { - return 'deletion' - } - - if (status.text.match(/started following/) || status.activity_type === 'follow') { - return 'follow' - } - - return 'unknown' -} - const mergeOrAdd = (arr, obj, item) => { // For sequential IDs BE passes numbers as numbers, we want them as strings. item.id = String(item.id) @@ -138,7 +100,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us const allStatuses = state.allStatuses const allStatusesObject = state.allStatusesObject - const timelineObject = state.timelines[timeline] + const timelineObject = typeof timeline === 'object' ? timeline : state.timelines[timeline] const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0 const older = timeline && maxNew < timelineObject.maxId @@ -154,13 +116,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us return } - const addStatus = (status, showImmediately, addToTimeline = true) => { - const result = mergeOrAdd(allStatuses, allStatusesObject, status) - status = result.item + const addStatus = (data, showImmediately, addToTimeline = true) => { + const result = mergeOrAdd(allStatuses, allStatusesObject, data) + const status = result.item if (result.new) { // We are mentioned in a post - if (statusType(status) === 'status' && find(status.attentions, { id: user.id })) { + if (status.type === 'status' && find(status.attentions, { id: user.id })) { const mentions = state.timelines.mentions // Add the mention to the mentions timeline @@ -264,6 +226,9 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us remove(timelineObject.visibleStatuses, { uri }) } }, + 'follow': (follow) => { + // NOOP, it is known status but we don't do anything about it for now + }, 'default': (unknown) => { console.log('unknown status type') console.log(unknown) @@ -271,7 +236,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us } each(statuses, (status) => { - const type = statusType(status) + const type = status.type const processor = processors[type] || processors['default'] processor(status) }) @@ -289,15 +254,11 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot const allStatuses = state.allStatuses const allStatusesObject = state.allStatusesObject each(notifications, (notification) => { - notification.notice.id = String(notification.notice.id) - const result = mergeOrAdd(allStatuses, allStatusesObject, notification.notice) - const action = result.item - // For sequential IDs BE passes numbers as numbers, we want them as strings. - action.id = String(action.id) + notification.action = mergeOrAdd(allStatuses, allStatusesObject, notification.action).item + notification.status = notification.status && mergeOrAdd(allStatuses, allStatusesObject, notification.status).item // Only add a new notification if we don't have one for the same action - // TODO: this technically works but should be checking for notification.id, not action.id i think - if (!find(state.notifications.data, (oldNotification) => oldNotification.action.id === action.id)) { + if (!state.notifications.idStore.hasOwnProperty(notification.id)) { state.notifications.maxId = notification.id > state.notifications.maxId ? notification.id : state.notifications.maxId @@ -305,35 +266,24 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot ? notification.id : state.notifications.minId - const fresh = !notification.is_seen - const status = notification.ntype === 'like' - ? action.favorited_status - : action - - const result = { - type: notification.ntype, - status, - action, - seen: !fresh - } - - state.notifications.data.push(result) - state.notifications.idStore[notification.id] = result + state.notifications.data.push(notification) + state.notifications.idStore[notification.id] = notification if ('Notification' in window && window.Notification.permission === 'granted') { + const notifObj = {} + const action = notification.action const title = action.user.name - const result = {} - result.icon = action.user.profile_image_url - result.body = action.text // there's a problem that it doesn't put a space before links tho + notifObj.icon = action.user.profile_image_url + notifObj.body = action.text // there's a problem that it doesn't put a space before links tho // Shows first attached non-nsfw image, if any. Should add configuration for this somehow... if (action.attachments && action.attachments.length > 0 && !action.nsfw && action.attachments[0].mimetype.startsWith('image/')) { - result.image = action.attachments[0].url + notifObj.image = action.attachments[0].url } - if (fresh && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.ntype)) { - let notification = new window.Notification(title, result) + if (notification.fresh && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.ntype)) { + let notification = new window.Notification(title, notifObj) // Chrome is known for not closing notifications automatically // according to MDN, anyway. setTimeout(notification.close.bind(notification), 5000) @@ -347,7 +297,7 @@ export const mutations = { addNewStatuses, addNewNotifications, showNewStatuses (state, { timeline }) { - const oldTimeline = (state.timelines[timeline]) + const oldTimeline = (typeof timeline === 'object' ? timeline : state.timelines[timeline]) oldTimeline.newStatusCount = 0 oldTimeline.visibleStatuses = slice(oldTimeline.statuses, 0, 50) @@ -356,7 +306,8 @@ export const mutations = { each(oldTimeline.visibleStatuses, (status) => { oldTimeline.visibleStatusesObject[status.id] = status }) }, clearTimeline (state, { timeline }) { - state.timelines[timeline] = emptyTl() + const timelineObject = typeof timeline === 'object' ? timeline : state.timelines[timeline] + emptyTl(timelineObject, timeline.userId) }, setFavorited (state, { status, value }) { const newStatus = state.allStatusesObject[status.id] @@ -376,7 +327,8 @@ export const mutations = { newStatus.deleted = true }, setLoading (state, { timeline, value }) { - state.timelines[timeline].loading = value + const timelineObject = typeof timeline === 'object' ? timeline : state.timelines[timeline] + timelineObject.loading = value }, setNsfw (state, { id, nsfw }) { const newStatus = state.allStatusesObject[id] @@ -397,7 +349,8 @@ export const mutations = { }) }, queueFlush (state, { timeline, id }) { - state.timelines[timeline].flushMarker = id + const timelineObject = typeof timeline === 'object' ? timeline : state.timelines[timeline] + timelineObject.flushMarker = id } } diff --git a/src/modules/users.js b/src/modules/users.js index 9d00b782..33c02a07 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -7,9 +7,6 @@ import { humanizeErrors } from './errors' // TODO: Unify with mergeOrAdd in statuses.js export const mergeOrAdd = (arr, obj, item) => { - // For sequential IDs BE passes numbers as numbers, we want them as strings. - item.id = String(item.id) - if (!item) { return false } const oldItem = obj[item.id] if (oldItem) { @@ -67,10 +64,11 @@ export const mutations = { each(users, (user) => mergeOrAdd(state.users, state.usersObject, user)) }, setUserForStatus (state, status) { - status.user = state.usersObject[String(status.user.id)] + status.user = state.usersObject[status.user.id] }, setUserForNotification (state, notification) { - notification.action.user = state.usersObject[String(notification.action.user.id)] + notification.action.user = state.usersObject[notification.action.user.id] + notification.from_profile = state.usersObject[notification.action.user.id] }, setColor (state, { user: { id }, highlighted }) { const user = state.usersObject[id] @@ -152,8 +150,8 @@ const users = { }) }, addNewNotifications (store, { notifications }) { - const users = compact(map(notifications, 'from_profile')) - const notificationIds = compact(notifications.map(_ => String(_.id))) + const users = map(notifications, 'from_profile') + const notificationIds = notifications.map(_ => String(_.id)) store.commit('addNewUsers', users) const notificationsObject = store.rootState.statuses.notifications.idStore diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 4ee95bd1..14a526ef 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -41,7 +41,10 @@ const APPROVE_USER_URL = '/api/pleroma/friendships/approve' const DENY_USER_URL = '/api/pleroma/friendships/deny' const SUGGESTIONS_URL = '/api/v1/suggestions' +const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites' + import { each, map } from 'lodash' +import { parseStatus, parseUser, parseNotification } from '../entity_normalizer/entity_normalizer.service.js' import 'whatwg-fetch' const oldfetch = window.fetch @@ -70,6 +73,7 @@ const updateAvatar = ({credentials, params}) => { form.append(key, value) } }) + return fetch(url, { headers: authHeaders(credentials), method: 'POST', @@ -87,6 +91,7 @@ const updateBg = ({credentials, params}) => { form.append(key, value) } }) + return fetch(url, { headers: authHeaders(credentials), method: 'POST', @@ -110,6 +115,7 @@ const updateBanner = ({credentials, params}) => { form.append(key, value) } }) + return fetch(url, { headers: authHeaders(credentials), method: 'POST', @@ -237,24 +243,28 @@ const fetchUser = ({id, credentials}) => { let url = `${USER_URL}?user_id=${id}` return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) + .then((data) => parseUser(data)) } const fetchFriends = ({id, credentials}) => { let url = `${FRIENDS_URL}?user_id=${id}` return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) + .then((data) => data.map(parseUser)) } const fetchFollowers = ({id, credentials}) => { let url = `${FOLLOWERS_URL}?user_id=${id}` return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) + .then((data) => data.map(parseUser)) } const fetchAllFollowing = ({username, credentials}) => { const url = `${ALL_FOLLOWING_URL}/${username}.json` return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) + .then((data) => data.map(parseUser)) } const fetchFollowRequests = ({credentials}) => { @@ -267,12 +277,26 @@ const fetchConversation = ({id, credentials}) => { let url = `${CONVERSATION_URL}/${id}.json?count=100` return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) + .then((data) => { + if (data.ok) { + return data + } + throw new Error('Error fetching timeline') + }) + .then((data) => data.map(parseStatus)) } const fetchStatus = ({id, credentials}) => { let url = `${STATUS_URL}/${id}.json` return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) + .then((data) => { + if (data.ok) { + return data + } + throw new Error('Error fetching timeline') + }) + .then((data) => parseStatus(data)) } const setUserMute = ({id, credentials, muted = true}) => { @@ -300,12 +324,14 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use notifications: QVITTER_USER_NOTIFICATIONS_URL, 'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL, user: QVITTER_USER_TIMELINE_URL, + favorites: MASTODON_USER_FAVORITES_TIMELINE_URL, tag: TAG_TIMELINE_URL } + const type = timeline.type || timeline + const isNotifications = type === 'notifications' + const params = [] - let url = timelineUrls[timeline] - - let params = [] + let url = timelineUrls[type] if (since) { params.push(['since_id', since]) @@ -333,6 +359,7 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use throw new Error('Error fetching timeline') }) .then((data) => data.json()) + .then((data) => data.map(isNotifications ? parseNotification : parseStatus)) } const verifyCredentials = (user) => { diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js new file mode 100644 index 00000000..adc7f047 --- /dev/null +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -0,0 +1,212 @@ +const qvitterStatusType = (status) => { + if (status.is_post_verb) { + return 'status' + } + + if (status.retweeted_status) { + return 'retweet' + } + + if ((typeof status.uri === 'string' && status.uri.match(/(fave|objectType=Favourite)/)) || + (typeof status.text === 'string' && status.text.match(/favorited/))) { + return 'favorite' + } + + if (status.text.match(/deleted notice {{tag/) || status.qvitter_delete_notice) { + return 'deletion' + } + + if (status.text.match(/started following/) || status.activity_type === 'follow') { + return 'follow' + } + + return 'unknown' +} + +export const parseUser = (data) => { + const output = {} + const masto = data.hasOwnProperty('acct') + // case for users in "mentions" property for statuses in MastoAPI + const mastoShort = masto && !data.hasOwnProperty('avatar') + + output.id = data.id + + if (masto) { + output.screen_name = data.acct + + // There's nothing else to get + if (mastoShort) { + return output + } + + output.name = null // missing + output.name_html = data.display_name + + output.description = null // missing + output.description_html = data.note + + // Utilize avatar_static for gif avatars? + output.profile_image_url = data.avatar + output.profile_image_url_original = data.avatar + + // Same, utilize header_static? + output.cover_photo = data.header + + output.friends_count = data.following_count + + output.bot = data.bot + + output.statusnet_profile_url = data.url + + // Missing, trying to recover + output.is_local = !output.screen_name.includes('@') + } else { + output.screen_name = data.screen_name + + output.name = data.name + output.name_html = data.name_html + + output.description = data.description + output.description_html = data.description_html + + output.profile_image_url = data.profile_image_url + output.profile_image_url_original = data.profile_image_url_original + + output.cover_photo = data.cover_photo + + output.friends_count = data.friends_count + + output.bot = null // missing + + output.statusnet_profile_url = data.statusnet_profile_url + output.is_local = data.is_local + } + + output.created_at = new Date(data.created_at) + output.locked = data.locked + output.followers_count = data.followers_count + output.statuses_count = data.statuses_count + + return output +} + +const parseAttachment = (data) => { + // TODO A little bit messy ATM but works with both APIs + return { + ...data, + mimetype: data.mimetype || data.type + } +} + +export const parseStatus = (data) => { + const output = {} + const masto = data.hasOwnProperty('account') + + if (masto) { + output.favorited = data.favourited + output.fave_num = data.favourites_count + + output.repeated = data.reblogged + output.repeat_num = data.reblogs_count + + output.type = data.reblog ? 'retweet' : 'status' + output.nsfw = data.sensitive + + output.statusnet_html = data.content + + // Not exactly the same but works? + output.text = data.content + + output.in_reply_to_status_id = data.in_reply_to_id + output.in_reply_to_user_id = data.in_reply_to_user_id + + // Not exactly the same but works + output.statusnet_conversation_id = data.id + } else { + output.favorited = data.favorited + output.fave_num = data.fave_num + + output.repeated = data.repeated + output.repeat_num = data.repeat_num + + // catchall, temporary + // Object.assign(output, data) + + output.type = qvitterStatusType(data) + + if (data.nsfw === undefined) { + output.nsfw = isNsfw(data) + if (data.retweeted_status) { + output.nsfw = data.retweeted_status.nsfw + } + } else { + output.nsfw = data.nsfw + } + + output.statusnet_html = data.statusnet_html + output.text = data.text + + output.in_reply_to_status_id = data.in_reply_to_id + output.in_reply_to_user_id = data.in_reply_to_account_id + + output.statusnet_conversation_id = data.statusnet_conversation_id + } + + output.id = Number(data.id) + output.visibility = data.visibility + output.created_at = new Date(data.created_at) + + output.user = parseUser(masto ? data.account : data.user) + + output.attentions = ((masto ? data.mentions : data.attentions) || []) + .map(_ => ({ + id: _.id, + following: _.following // FIXME: MastoAPI doesn't have this + })) + + output.attachments = ((masto ? data.media_attachments : data.attachments) || []) + .map(parseAttachment) + + const retweetedStatus = masto ? data.reblog : data.retweeted_status + if (retweetedStatus) { + output.retweeted_status = parseStatus(retweetedStatus) + } + + return output +} + +export const parseNotification = (data) => { + const mastoDict = { + 'favourite': 'like', + 'reblog': 'repeat' + } + const masto = !data.hasOwnProperty('ntype') + const output = {} + + if (masto) { + output.type = mastoDict[data.type] || data.type + output.seen = null // missing + output.status = parseStatus(data.status) + output.action = null // missing + output.from_profile = parseUser(data.account) + } else { + const parsedNotice = parseStatus(data.notice) + output.type = data.ntype + output.seen = data.is_seen + output.status = output.type === 'like' + ? parseStatus(data.notice.favorited_status) + : parsedNotice + output.action = parsedNotice + output.from_profile = parseUser(data.from_profile) + } + + output.created_at = new Date(data.created_at) + output.id = data.id + + return output +} + +const isNsfw = (status) => { + const nsfwRegex = /#nsfw/i + return (status.tags || []).includes('nsfw') || !!status.text.match(nsfwRegex) +} diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js index c2a7de56..126e07cf 100644 --- a/src/services/timeline_fetcher/timeline_fetcher.service.js +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -3,7 +3,7 @@ import { camelCase } from 'lodash' import apiService from '../api/api.service.js' const update = ({store, statuses, timeline, showImmediately, userId}) => { - const ccTimeline = camelCase(timeline) + const ccTimeline = typeof timeline === 'object' ? timeline : camelCase(timeline) store.dispatch('setError', { value: false }) @@ -18,7 +18,7 @@ const update = ({store, statuses, timeline, showImmediately, userId}) => { const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false, showImmediately = false, userId = false, tag = false, until}) => { const args = { timeline, credentials } const rootState = store.rootState || store.state - const timelineData = rootState.statuses.timelines[camelCase(timeline)] + const timelineData = typeof timeline === 'object' ? timeline : rootState.statuses.timelines[camelCase(timeline)] if (older) { args['until'] = until || timelineData.minVisibleId @@ -31,7 +31,7 @@ const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false return apiService.fetchTimeline(args) .then((statuses) => { - if (!older && statuses.length >= 20 && !timelineData.loading) { + if (!older && statuses.length >= 20 && !timelineData.loading && timelineData.statuses.length) { store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId }) } update({store, statuses, timeline, showImmediately, userId}) |
